From 17eaf47ebc52a870fd33e5594e6fd9d392274616 Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Tue, 4 Mar 2025 00:12:47 -0500 Subject: [PATCH 1/2] Big exception refactor and simplification --- TODO.md | 16 +- docs/VALIDATIONS_AND_EXCEPTIONS.md | 121 ++++++++++--- fitbit_client/auth/callback_server.py | 5 +- fitbit_client/auth/oauth.py | 163 +++++++++++++----- fitbit_client/client.py | 26 ++- fitbit_client/exceptions.py | 81 ++++++--- .../resources/active_zone_minutes.py | 10 +- fitbit_client/resources/activity.py | 8 +- .../resources/heartrate_timeseries.py | 23 ++- fitbit_client/resources/nutrition.py | 18 +- fitbit_client/resources/sleep.py | 13 +- tests/auth/test_callback_server.py | 4 +- tests/auth/test_oauth.py | 162 ++++++++++------- .../test_get_azm_timeseries_by_date.py | 8 +- .../activity/test_create_activity_log.py | 12 +- .../test_get_heartrate_timeseries_by_date.py | 8 +- ..._get_heartrate_timeseries_by_date_range.py | 6 +- tests/resources/nutrition/test_create_food.py | 1 - .../nutrition/test_create_food_goal.py | 6 +- .../nutrition/test_create_food_log.py | 2 +- .../nutrition/test_update_food_log.py | 6 +- .../sleep/test_create_sleep_goals.py | 8 +- .../resources/sleep/test_create_sleep_log.py | 6 +- tests/test_client.py | 21 ++- tests/test_exceptions.py | 38 ++++ tests/utils/test_date_validation.py | 2 - 26 files changed, 551 insertions(+), 223 deletions(-) diff --git a/TODO.md b/TODO.md index 18b8ea2..222ccd8 100644 --- a/TODO.md +++ b/TODO.md @@ -45,18 +45,10 @@ if not food_id and not (food_name and calories): see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource) -- exceptions.py - - - Should ClientValidationException really subclass FitbitAPIException? IT - SHOULD SUBCLASS ValueError doesn't need the API lookup mapping - (`exception_type`) or a `status_code`, so we may just be able to simplify - it. The most important thing is that the user understands that the message - came from the client prior to the API call. - - - Make sure we aren't using - - - Make sure that `ClientValidationException` is getting used for arbitrary - validations like +- exceptions.py Consider: + - Add automatic token refresh for ExpiredTokenException + - Implement backoff and retry for RateLimitExceededException + - Add retry with exponential backoff for transient errors (5xx) ## Longer term TODOs diff --git a/docs/VALIDATIONS_AND_EXCEPTIONS.md b/docs/VALIDATIONS_AND_EXCEPTIONS.md index 13d2851..76752d1 100644 --- a/docs/VALIDATIONS_AND_EXCEPTIONS.md +++ b/docs/VALIDATIONS_AND_EXCEPTIONS.md @@ -1,10 +1,16 @@ # Input Validation and Error Handling -Many method parameter arguments are validated before making API requests. The -aim is to encapulate the HTTP API as much as possible and raise more helpfule -exceptions before a bad request is executed. Understanding these validations and -the exceptions that are raised by them (and elsewhere) will help you use this -library correctly. +Many method parameter arguments are validated **before making any API +requests**. The aim is to encapsulate the HTTP API as much as possible and raise +more helpful exceptions before a bad request is executed. This approach: + +- Preserves your API rate limits by catching errors locally +- Provides more specific and helpful error messages +- Simplifies debugging by clearly separating client-side validation issues from + API response issues + +Understanding these validations and the exceptions that are raised by them (and +elsewhere) will help you use this library correctly and efficiently. ## Input Validation @@ -167,9 +173,52 @@ except ValidationException as e: ## Exception Handling -There are many custom exceptions, When validation fails or other errors occur, +There are many custom exceptions. When validation fails or other errors occur, the library raises specific exceptions that help identify the problem. +### Using Custom Validation Exceptions + +Client validation exceptions (`ClientValidationException` and its subclasses) +are raised *before* any API call is made. This means: + +1. They reflect problems with your input parameters that can be detected locally +2. No network requests have been initiated when these exceptions occur +3. They help you fix issues before consuming API rate limits + +This is in contrast to API exceptions (`FitbitAPIException` and its subclasses), +which are raised in response to errors returned by the Fitbit API after a +network request has been made. + +When using this library, you'll want to catch the specific exception types for +proper error handling: + +```python +from fitbit_client.exceptions import ParameterValidationException, MissingParameterException + +try: + # When parameters might be missing + client.nutrition.create_food_goal(calories=None, intensity=None) +except MissingParameterException as e: + print(f"Missing parameter: {e.message}") + +try: + # When parameters might be invalid + client.sleep.create_sleep_goals(min_duration=-10) +except ParameterValidationException as e: + print(f"Invalid parameter value for {e.field_name}: {e.message}") +``` + +You can also catch the base class for all client validation exceptions: + +```python +from fitbit_client.exceptions import ClientValidationException + +try: + client.activity.create_activity_log(duration_millis=-100, start_time="12:00", date="2024-02-20") +except ClientValidationException as e: + print(f"Validation error: {e.message}") +``` + ### ValidationException Raised when input parameters do not meet requirements: @@ -238,17 +287,49 @@ except RateLimitExceededException as e: ### Exception Properties -All exceptions provide these properties: +API exceptions (`FitbitAPIException` and its subclasses) provide these +properties: - `message`: Human-readable error description - `status_code`: HTTP status code (if applicable) - `error_type`: Type of error from the API - `field_name`: Name of the invalid field (for validation errors) +Validation exceptions (`ClientValidationException` and its subclasses) provide: + +- `message`: Human-readable error description +- `field_name`: Name of the invalid field (for validation errors) + +Specific validation exception subclasses provide additional properties: + +- `InvalidDateException`: Adds `date_str` property with the invalid date string +- `InvalidDateRangeException`: Adds `start_date`, `end_date`, `max_days`, and + `resource_name` properties +- `IntradayValidationException`: Adds `allowed_values` and `resource_name` + properties +- `ParameterValidationException`: Used for invalid parameter values (e.g., + negative where positive is required) +- `MissingParameterException`: Used when required parameters are missing or + parameter combinations are invalid + ### Exception Hierarchy: ``` Exception +├── ValueError +│ └── ClientValidationException # Superclass for validations that take place before +│ │ # making a request +│ ├── InvalidDateException # Raised when a date string is not in the correct +│ │ # format or not a valid calendar date +│ ├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is +│ │ # before start, exceeds max days) +│ ├── PaginationException # Raised when pagination parameters are invalid +│ ├── IntradayValidationException # Raised when intraday request parameters are invalid +│ ├── ParameterValidationException # Raised when a parameter value is invalid +│ │ # (e.g., negative when positive required) +│ └── MissingParameterException # Raised when required parameters are missing or +│ # parameter combinations are invalid +│ └── FitbitAPIException # Base exception for all Fitbit API errors │ ├── OAuthException # Superclass for all authentication flow exceptions @@ -257,23 +338,15 @@ Exception │ ├── InvalidTokenException # Raised when the OAuth token is invalid │ └── InvalidClientException # Raised when the client_id is invalid │ - ├── RequestException # Superclass for all API request exceptions - │ ├── InvalidRequestException # Raised when the request syntax is invalid - │ ├── AuthorizationException # Raised when there are authorization-related errors - │ ├── InsufficientPermissionsException # Raised when the application has insufficient permissions - │ ├── InsufficientScopeException # Raised when the application is missing a required scope - │ ├── NotFoundException # Raised when the requested resource does not exist - │ ├── RateLimitExceededException # Raised when the application hits rate limiting quotas - │ ├── SystemException # Raised when there is a system-level failure - │ └── ValidationException # Raised when a request parameter is invalid or missing - │ - └── ClientValidationException # Superclass for validations that take place before - │ # making a request - ├── InvalidDateException # Raised when a date string is not in the correct - │ # format or not a valid calendar date - ├── InvalidDateRangeException # Raised when a date range is invalid (e.g., end is - │ # before start, exceeds max days) - └── IntradayValidationException # Raised when intraday request parameters are invalid + └── RequestException # Superclass for all API request exceptions + ├── InvalidRequestException # Raised when the request syntax is invalid + ├── AuthorizationException # Raised when there are authorization-related errors + ├── InsufficientPermissionsException # Raised when the application has insufficient permissions + ├── InsufficientScopeException # Raised when the application is missing a required scope + ├── NotFoundException # Raised when the requested resource does not exist + ├── RateLimitExceededException # Raised when the application hits rate limiting quotas + ├── SystemException # Raised when there is a system-level failure + └── ValidationException # Raised when a request parameter is invalid or missing ``` ## Debugging diff --git a/fitbit_client/auth/callback_server.py b/fitbit_client/auth/callback_server.py index 750c343..0d0d1df 100644 --- a/fitbit_client/auth/callback_server.py +++ b/fitbit_client/auth/callback_server.py @@ -54,7 +54,7 @@ def __init__(self, redirect_uri: str) -> None: raise InvalidRequestException( message="Request to invalid domain: redirect_uri must use HTTPS protocol.", status_code=400, - error_type="request", + error_type="invalid_request", field_name="redirect_uri", ) @@ -237,9 +237,10 @@ def wait_for_callback(self, timeout: int = 300) -> Optional[str]: self.logger.error("Callback wait timed out") raise InvalidRequestException( - message="OAuth callback timed out waiting for response", + message=f"OAuth callback timed out after {timeout} seconds", status_code=400, error_type="invalid_request", + field_name="oauth_callback", ) def stop(self) -> None: diff --git a/fitbit_client/auth/oauth.py b/fitbit_client/auth/oauth.py index 3185d1d..7cdc492 100644 --- a/fitbit_client/auth/oauth.py +++ b/fitbit_client/auth/oauth.py @@ -73,7 +73,8 @@ def __init__( raise InvalidRequestException( message="This request should use https protocol.", status_code=400, - error_type="request", + error_type="invalid_request", + field_name="redirect_uri", ) environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -128,7 +129,14 @@ def _load_token(self) -> Optional[TokenDict]: except InvalidGrantException: # Invalid/expired refresh token return None - except Exception: + except json.JSONDecodeError: + self.logger.error(f"Invalid JSON in token cache file: {self.token_cache_path}") + return None + except OSError as e: + self.logger.error(f"Error reading token cache file: {self.token_cache_path}: {str(e)}") + return None + except Exception as e: + self.logger.error(f"Unexpected error loading token: {e.__class__.__name__}: {str(e)}") return None return None @@ -139,7 +147,23 @@ def _save_token(self, token: TokenDict) -> None: self.token = token def authenticate(self, force_new: bool = False) -> bool: - """Complete authentication flow if needed""" + """Complete authentication flow if needed + + Args: + force_new: Force new authentication even if valid token exists + + Returns: + bool: True if authenticated successfully + + Raises: + InvalidRequestException: If the request syntax is invalid + InvalidClientException: If the client_id is invalid + InvalidGrantException: If the grant_type is invalid + InvalidTokenException: If the OAuth token is invalid + ExpiredTokenException: If the OAuth token has expired + OAuthException: Base class for all OAuth-related exceptions + SystemException: If there's a system-level failure + """ if not force_new and self.is_authenticated(): self.logger.debug("Authentication token exchange completed successfully") return True @@ -164,18 +188,9 @@ def authenticate(self, force_new: bool = False) -> bool: callback_url = input("Enter the full callback URL: ") # Exchange authorization code for token - try: - token = self.fetch_token(callback_url) - self._save_token(token) - return True - except Exception as e: - if "invalid_grant" in str(e): - raise InvalidGrantException( - message="Authorization code expired or invalid", - status_code=400, - error_type="invalid_grant", - ) from e - raise + token = self.fetch_token(callback_url) + self._save_token(token) + return True def is_authenticated(self) -> bool: """Check if we have valid tokens""" @@ -192,7 +207,21 @@ def get_authorization_url(self) -> Tuple[str, str]: return (str(auth_url_tuple[0]), str(auth_url_tuple[1])) def fetch_token(self, authorization_response: str) -> TokenDict: - """Exchange authorization code for access token""" + """Exchange authorization code for access token + + Args: + authorization_response: The full callback URL with authorization code + + Returns: + TokenDict: Dictionary containing access token and other OAuth details + + Raises: + InvalidClientException: If the client credentials are invalid + InvalidTokenException: If the authorization code is invalid + InvalidGrantException: If the authorization grant is invalid + ExpiredTokenException: If the token has expired + OAuthException: For other OAuth-related errors + """ try: auth = HTTPBasicAuth(self.client_id, self.client_secret) token_data = self.session.fetch_token( @@ -208,31 +237,54 @@ def fetch_token(self, authorization_response: str) -> TokenDict: except Exception as e: error_msg = str(e).lower() - if "invalid_client" in error_msg: - self.logger.error( - f"InvalidClientException: Authentication failed " - f"(Client ID: {self.client_id[:4]}..., Error: {str(e)})" - ) - raise InvalidClientException( - message="Invalid client credentials", - status_code=401, - error_type="invalid_client", - ) from e - if "invalid_token" in error_msg: - self.logger.error( - f"InvalidTokenException: Token validation failed " f"(Error: {str(e)})" - ) - raise InvalidTokenException( - message="Invalid authorization code", - status_code=401, - error_type="invalid_token", - ) from e + # Use standard error mapping from ERROR_TYPE_EXCEPTIONS + # Local imports + from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS + from fitbit_client.exceptions import OAuthException + + # Check for known error types + for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items(): + if error_type in error_msg: + # Special case for client ID to mask most of it in logs + if error_type == "invalid_client": + self.logger.error( + f"{exception_class.__name__}: Authentication failed " + f"(Client ID: {self.client_id[:4]}..., Error: {str(e)})" + ) + else: + self.logger.error( + f"{exception_class.__name__}: {error_type} error during token fetch: {str(e)}" + ) - self.logger.error(f"OAuthException: {e.__class__.__name__}: {str(e)}") - raise + raise exception_class( + message=str(e), + status_code=( + 401 if "token" in error_type or error_type == "authorization" else 400 + ), + error_type=error_type, + ) from e + + # If no specific error type found, use OAuthException + self.logger.error( + f"OAuthException during token fetch: {e.__class__.__name__}: {str(e)}" + ) + raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e def refresh_token(self, refresh_token: str) -> TokenDict: - """Refresh the access token""" + """Refresh the access token + + Args: + refresh_token: The refresh token to use + + Returns: + TokenDict: Dictionary containing new access token and other OAuth details + + Raises: + ExpiredTokenException: If the access token has expired + InvalidGrantException: If the refresh token is invalid + InvalidClientException: If the client credentials are invalid + OAuthException: For other OAuth-related errors + """ try: auth = HTTPBasicAuth(self.client_id, self.client_secret) extra = { @@ -248,12 +300,29 @@ def refresh_token(self, refresh_token: str) -> TokenDict: return token except Exception as e: error_msg = str(e).lower() - if "expired_token" in error_msg: - raise ExpiredTokenException( - message="Access token expired", status_code=401, error_type="expired_token" - ) from e - if "invalid_grant" in error_msg: - raise InvalidGrantException( - message="Refresh token invalid", status_code=400, error_type="invalid_grant" - ) from e - raise + + # Use standard error mapping from ERROR_TYPE_EXCEPTIONS + # Local imports + from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS + from fitbit_client.exceptions import OAuthException + + # Check for known error types + for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items(): + if error_type in error_msg: + self.logger.error( + f"{exception_class.__name__}: {error_type} error during token refresh: {str(e)}" + ) + + raise exception_class( + message=str(e), + status_code=( + 401 if "token" in error_type or error_type == "authorization" else 400 + ), + error_type=error_type, + ) from e + + # If no specific error type found, use OAuthException + self.logger.error( + f"OAuthException during token refresh: {e.__class__.__name__}: {str(e)}" + ) + raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e diff --git a/fitbit_client/client.py b/fitbit_client/client.py index c8188d0..8f9b672 100644 --- a/fitbit_client/client.py +++ b/fitbit_client/client.py @@ -6,7 +6,17 @@ # fmt: off # isort: off +# Auth imports from fitbit_client.auth.oauth import FitbitOAuth2 +from fitbit_client.exceptions import ExpiredTokenException +from fitbit_client.exceptions import InvalidClientException +from fitbit_client.exceptions import InvalidGrantException +from fitbit_client.exceptions import InvalidRequestException +from fitbit_client.exceptions import InvalidTokenException +from fitbit_client.exceptions import OAuthException +from fitbit_client.exceptions import SystemException + +# Resource imports from fitbit_client.resources.active_zone_minutes import ActiveZoneMinutesResource from fitbit_client.resources.activity import ActivityResource from fitbit_client.resources.activity_timeseries import ActivityTimeSeriesResource @@ -114,12 +124,24 @@ def authenticate(self, force_new: bool = False) -> bool: Returns: bool: True if authenticated successfully + + Raises: + OAuthException: Base class for all OAuth-related exceptions + ExpiredTokenException: If the OAuth token has expired + InvalidClientException: If the client_id is invalid + InvalidGrantException: If the grant_type is invalid + InvalidTokenException: If the OAuth token is invalid + InvalidRequestException: If the request syntax is invalid + SystemException: If there's a system-level failure during authentication """ self.logger.debug(f"Starting authentication (force_new={force_new})") try: result = self.auth.authenticate(force_new=force_new) self.logger.debug("Authentication successful") return result - except Exception as e: - self.logger.error(f"Authentication failed: {str(e)}") + except OAuthException as e: + self.logger.error(f"Authentication failed: {e.__class__.__name__}: {str(e)}") + raise + except SystemException as e: + self.logger.error(f"System error during authentication: {str(e)}") raise diff --git a/fitbit_client/exceptions.py b/fitbit_client/exceptions.py index 6fe209b..708df35 100644 --- a/fitbit_client/exceptions.py +++ b/fitbit_client/exceptions.py @@ -119,23 +119,23 @@ class ValidationException(RequestException): ## PreRequestValidaton Exceptions -class ClientValidationException(FitbitAPIException): - """Superclass for validations that take place before making a request""" +class ClientValidationException(ValueError): + """Superclass for validations that take place before making any API request. - def __init__( - self, - message: str, - error_type: str = "client_validation", - field_name: Optional[str] = None, - raw_response: Optional[Dict[str, Any]] = None, - ): - super().__init__( - message=message, - error_type=error_type, - status_code=None, - raw_response=raw_response, - field_name=field_name, - ) + These exceptions indicate that input validation failed locally, without making + any network requests. This helps preserve API rate limits and gives more specific + error information than would be available from the API response.""" + + def __init__(self, message: str, field_name: Optional[str] = None): + """Initialize client validation exception. + + Args: + message: Human-readable error message + field_name: Optional name of the invalid field + """ + self.message = message + self.field_name = field_name + super().__init__(self.message) class InvalidDateException(ClientValidationException): @@ -144,9 +144,15 @@ class InvalidDateException(ClientValidationException): def __init__( self, date_str: str, field_name: Optional[str] = None, message: Optional[str] = None ): + """Initialize invalid date exception. + + Args: + date_str: The invalid date string + field_name: Optional name of the date field + message: Optional custom error message. If not provided, a default message is generated. + """ super().__init__( message=message or f"Invalid date format. Expected YYYY-MM-DD, got: {date_str}", - error_type="invalid_date", field_name=field_name, ) self.date_str = date_str @@ -163,10 +169,19 @@ def __init__( max_days: Optional[int] = None, resource_name: Optional[str] = None, ): + """Initialize invalid date range exception. + + Args: + start_date: The start date of the invalid range + end_date: The end date of the invalid range + reason: Specific reason why the date range is invalid + max_days: Optional maximum number of days allowed for this request + resource_name: Optional resource or endpoint name for context + """ # Use the provided reason directly - don't override it message = f"Invalid date range: {reason}" - super().__init__(message=message, error_type="invalid_date_range", field_name="date_range") + super().__init__(message=message, field_name="date_range") self.start_date = start_date self.end_date = end_date self.max_days = max_days @@ -183,7 +198,7 @@ def __init__(self, message: str, field_name: Optional[str] = None): message: Error message describing the validation failure field_name: Optional name of the invalid field """ - super().__init__(message=message, error_type="pagination", field_name=field_name) + super().__init__(message=message, field_name=field_name) class IntradayValidationException(ClientValidationException): @@ -210,11 +225,37 @@ def __init__( if resource_name: error_msg = f"{error_msg} for {resource_name}" - super().__init__(message=error_msg, field_name=field_name, error_type="intraday_validation") + super().__init__(message=error_msg, field_name=field_name) self.allowed_values = allowed_values self.resource_name = resource_name +class ParameterValidationException(ClientValidationException): + """Raised when a parameter value is invalid (e.g., negative when positive required)""" + + def __init__(self, message: str, field_name: Optional[str] = None): + """Initialize parameter validation exception + + Args: + message: Error message describing the validation failure + field_name: Optional name of the invalid field + """ + super().__init__(message=message, field_name=field_name) + + +class MissingParameterException(ClientValidationException): + """Raised when required parameters are missing or parameter combinations are invalid""" + + def __init__(self, message: str, field_name: Optional[str] = None): + """Initialize missing parameter exception + + Args: + message: Error message describing the validation failure + field_name: Optional name of the invalid or missing field + """ + super().__init__(message=message, field_name=field_name) + + # Map HTTP status codes to exception classes STATUS_CODE_EXCEPTIONS = { 400: InvalidRequestException, diff --git a/fitbit_client/resources/active_zone_minutes.py b/fitbit_client/resources/active_zone_minutes.py index d8a5a73..1b30798 100644 --- a/fitbit_client/resources/active_zone_minutes.py +++ b/fitbit_client/resources/active_zone_minutes.py @@ -6,6 +6,7 @@ from typing import cast # Local imports +from fitbit_client.exceptions import IntradayValidationException from fitbit_client.resources.base import BaseResource from fitbit_client.resources.constants import Period from fitbit_client.utils.date_validation import validate_date_param @@ -59,7 +60,7 @@ def get_azm_timeseries_by_date( JSONDict: Daily Active Zone Minutes data Raises: - ValueError: If period is not Period.ONE_DAY + fitbit_client.exceptions.IntradayValidationException: If period is not Period.ONE_DAY fitbit_client.exceptions.InvalidDateException: If date format is invalid Note: @@ -72,7 +73,12 @@ def get_azm_timeseries_by_date( - Days with no AZM data will show all metrics as zero """ if period != Period.ONE_DAY: - raise ValueError("Only 1d period is supported for AZM time series") + raise IntradayValidationException( + message="Only 1d period is supported for AZM time series", + field_name="period", + allowed_values=[Period.ONE_DAY.value], + resource_name="active zone minutes", + ) result = self._make_request( f"activities/active-zone-minutes/date/{date}/{period.value}.json", diff --git a/fitbit_client/resources/activity.py b/fitbit_client/resources/activity.py index faf5405..9114cd9 100644 --- a/fitbit_client/resources/activity.py +++ b/fitbit_client/resources/activity.py @@ -8,6 +8,7 @@ from typing import cast # Local imports +from fitbit_client.exceptions import MissingParameterException from fitbit_client.exceptions import ValidationException from fitbit_client.resources.base import BaseResource from fitbit_client.resources.constants import ActivityGoalPeriod @@ -137,7 +138,7 @@ def create_activity_log( JSONDict: The created activity log entry with details of the recorded activity Raises: - ValueError: If neither activity_id nor activity_name/manual_calories pair is provided + fitbit_client.exceptions.MissingParameterException: If neither activity_id nor activity_name/manual_calories pair is provided fitbit_client.exceptions.InvalidDateException: If date format is invalid fitbit_client.exceptions.ValidationException: If required parameters are missing @@ -170,8 +171,9 @@ def create_activity_log( "date": date, } else: - raise ValueError( - "Must provide either activity_id or (activity_name and manual_calories)" + raise MissingParameterException( + message="Must provide either activity_id or (activity_name and manual_calories)", + field_name="activity_id/activity_name", ) result = self._make_request( diff --git a/fitbit_client/resources/heartrate_timeseries.py b/fitbit_client/resources/heartrate_timeseries.py index d7192f2..aa7d8e7 100644 --- a/fitbit_client/resources/heartrate_timeseries.py +++ b/fitbit_client/resources/heartrate_timeseries.py @@ -5,6 +5,8 @@ from typing import cast # Local imports +from fitbit_client.exceptions import IntradayValidationException +from fitbit_client.exceptions import ParameterValidationException from fitbit_client.resources.base import BaseResource from fitbit_client.resources.constants import Period from fitbit_client.utils.date_validation import validate_date_param @@ -59,8 +61,8 @@ def get_heartrate_timeseries_by_date( JSONDict: Heart rate data for each day in the period, including heart rate zones and resting heart rate Raises: - ValueError: If period is not one of the supported period values - ValueError: If timezone is provided and not 'UTC' + fitbit_client.exceptions.IntradayValidationException: If period is not one of the supported period values + fitbit_client.exceptions.ParameterValidationException: If timezone is provided and not 'UTC' fitbit_client.exceptions.InvalidDateException: If date format is invalid fitbit_client.exceptions.AuthorizationException: If the required scope is not granted @@ -91,12 +93,17 @@ def get_heartrate_timeseries_by_date( } if period not in supported_periods: - raise ValueError( - f"Period must be one of: {', '.join(p.value for p in supported_periods)}" + raise IntradayValidationException( + message=f"Period must be one of the supported values", + field_name="period", + allowed_values=[p.value for p in supported_periods], + resource_name="heart rate", ) if timezone is not None and timezone != "UTC": - raise ValueError("Only 'UTC' timezone is supported") + raise ParameterValidationException( + message="Only 'UTC' timezone is supported", field_name="timezone" + ) params = {"timezone": timezone} if timezone else None result = self._make_request( @@ -135,7 +142,7 @@ def get_heartrate_timeseries_by_date_range( JSONDict: Heart rate data for each day in the date range, including heart rate zones and resting heart rate Raises: - ValueError: If timezone is provided and not 'UTC' + fitbit_client.exceptions.ParameterValidationException: If timezone is provided and not 'UTC' fitbit_client.exceptions.InvalidDateException: If date format is invalid fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date or if the date range exceeds the maximum allowed (1095 days) @@ -158,7 +165,9 @@ def get_heartrate_timeseries_by_date_range( method, but allows for more precise control over the date range. """ if timezone is not None and timezone != "UTC": - raise ValueError("Only 'UTC' timezone is supported") + raise ParameterValidationException( + message="Only 'UTC' timezone is supported", field_name="timezone" + ) params = {"timezone": timezone} if timezone else None result = self._make_request( diff --git a/fitbit_client/resources/nutrition.py b/fitbit_client/resources/nutrition.py index e98b4c1..695c432 100644 --- a/fitbit_client/resources/nutrition.py +++ b/fitbit_client/resources/nutrition.py @@ -9,6 +9,7 @@ # Local imports from fitbit_client.exceptions import ClientValidationException +from fitbit_client.exceptions import MissingParameterException from fitbit_client.exceptions import ValidationException from fitbit_client.resources.base import BaseResource from fitbit_client.resources.constants import FoodFormType @@ -151,9 +152,7 @@ def create_food( and not isinstance(nutritional_values[NutritionalValue.CALORIES_FROM_FAT], int) ): raise ClientValidationException( - message="Calories from fat must be an integer", - error_type="client_validation", - field_name="CALORIES_FROM_FAT", + message="Calories from fat must be an integer", field_name="CALORIES_FROM_FAT" ) for key, value in nutritional_values.items(): if isinstance(key, NutritionalValue): @@ -295,7 +294,7 @@ def create_food_goal( (if enabled) Raises: - ValueError: If neither calories nor intensity is provided + fitbit_client.exceptions.MissingParameterException: If neither calories nor intensity is provided fitbit_client.exceptions.AuthorizationException: If required scope is not granted fitbit_client.exceptions.ValidationException: If parameters are invalid @@ -318,7 +317,9 @@ def create_food_goal( accounts for the user's activity levels rather than a fixed calorie goal. """ if not calories and not intensity: - raise ValueError("Must provide either calories or intensity") + raise MissingParameterException( + message="Must provide either calories or intensity", field_name="calories/intensity" + ) params: ParamDict = {} if calories: @@ -1095,7 +1096,7 @@ def update_food_log( amount, calories, and nutritional values reflecting the changes Raises: - fitbit_client.exceptions.ValueError: If neither (unit_id and amount) nor calories are provided + fitbit_client.exceptions.MissingParameterException: If neither (unit_id and amount) nor calories are provided fitbit_client.exceptions.NotFoundException: If the food log ID doesn't exist fitbit_client.exceptions.AuthorizationException: If required scope is not granted @@ -1117,7 +1118,10 @@ def update_food_log( elif calories: params["calories"] = calories else: - raise ValueError("Must provide either (unit_id and amount) or calories") + raise MissingParameterException( + message="Must provide either (unit_id and amount) or calories", + field_name="unit_id/amount/calories", + ) result = self._make_request( f"foods/log/{food_log_id}.json", diff --git a/fitbit_client/resources/sleep.py b/fitbit_client/resources/sleep.py index f05dcb9..3d4457c 100644 --- a/fitbit_client/resources/sleep.py +++ b/fitbit_client/resources/sleep.py @@ -7,6 +7,7 @@ from typing import cast # Local imports +from fitbit_client.exceptions import ParameterValidationException from fitbit_client.resources.base import BaseResource from fitbit_client.resources.constants import SortDirection from fitbit_client.utils.date_validation import validate_date_param @@ -50,7 +51,7 @@ def create_sleep_goals( JSONDict: Sleep goal details including minimum duration and update timestamp Raises: - ValueError: If min_duration is not positive + fitbit_client.exceptions.ParameterValidationException: If min_duration is not positive Note: Sleep goals help users track and maintain healthy sleep habits. @@ -58,7 +59,9 @@ def create_sleep_goals( (7-8 hours) per night. """ if min_duration <= 0: - raise ValueError("min_duration must be positive") + raise ParameterValidationException( + message="min_duration must be positive", field_name="min_duration" + ) result = self._make_request( "sleep/goal.json", @@ -100,7 +103,7 @@ def create_sleep_log( JSONDict: Created sleep log entry with sleep metrics and summary information Raises: - ValueError: If duration_millis is not positive + fitbit_client.exceptions.ParameterValidationException: If duration_millis is not positive fitbit_client.exceptions.InvalidDateException: If date format is invalid fitbit_client.exceptions.ValidationException: If time or duration is invalid fitbit_client.exceptions.AuthorizationException: If required scope is not granted @@ -117,7 +120,9 @@ def create_sleep_log( This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. """ if duration_millis <= 0: - raise ValueError("duration_millis must be positive") + raise ParameterValidationException( + message="duration_millis must be positive", field_name="duration_millis" + ) params = {"startTime": start_time, "duration": duration_millis, "date": date} result = self._make_request( diff --git a/tests/auth/test_callback_server.py b/tests/auth/test_callback_server.py index 4509d04..4fc6719 100644 --- a/tests/auth/test_callback_server.py +++ b/tests/auth/test_callback_server.py @@ -56,7 +56,7 @@ def test_initialization_requires_https(self): CallbackServer("http://localhost:8080") assert exc_info.value.status_code == 400 - assert exc_info.value.error_type == "request" + assert exc_info.value.error_type == "invalid_request" assert exc_info.value.field_name == "redirect_uri" assert "must use HTTPS protocol" in str(exc_info.value) @@ -345,6 +345,8 @@ def test_wait_for_callback_timeout(self, server): assert exc_info.value.status_code == 400 assert exc_info.value.error_type == "invalid_request" assert "timed out" in str(exc_info.value) + assert exc_info.value.field_name == "oauth_callback" + assert "1 seconds" in str(exc_info.value) def test_wait_for_callback_success(self, server): """Test successful callback handling""" diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py index 9093d30..9998bb6 100644 --- a/tests/auth/test_oauth.py +++ b/tests/auth/test_oauth.py @@ -69,7 +69,8 @@ def test_https_required(self): assert "should use https protocol" in str(exc_info.value) assert exc_info.value.status_code == 400 - assert exc_info.value.error_type == "request" + assert exc_info.value.error_type == "invalid_request" + assert exc_info.value.field_name == "redirect_uri" # PKCE Tests def test_code_verifier_length_validation(self, oauth): @@ -205,28 +206,27 @@ def test_authenticate_force_new(self, oauth): oauth.fetch_token.assert_called_once_with(mock_auth_response) oauth._save_token.assert_called_once_with(mock_token) - def test_authenticate_invalid_grant(self, oauth): - """Test authentication failure due to invalid grant during token fetch""" - mock_auth_response = "https://localhost:8080/callback?code=invalid_code&state=test_state" - - class MockException(Exception): - def __str__(self): - return "invalid_grant" + def test_authenticate_uses_fetch_token_directly(self, oauth): + """Test that authenticate passes callback URL directly to fetch_token""" + mock_auth_response = "https://localhost:8080/callback?code=test_code&state=test_state" + mock_token = { + "access_token": "test_token", + "refresh_token": "test_refresh", + "expires_at": time() + 3600, + } + # Setup mocks oauth.get_authorization_url = Mock(return_value=("test_url", "test_state")) - oauth.fetch_token = Mock(side_effect=MockException()) + oauth.fetch_token = Mock(return_value=mock_token) oauth.is_authenticated = Mock(return_value=False) + oauth._save_token = Mock() - with ( - patch("builtins.input", return_value=mock_auth_response), - patch("webbrowser.open"), - raises(InvalidGrantException) as exc_info, - ): + with patch("builtins.input", return_value=mock_auth_response), patch("webbrowser.open"): oauth.authenticate() - assert "Authorization code expired or invalid" in str(exc_info.value) - assert exc_info.value.status_code == 400 - assert exc_info.value.error_type == "invalid_grant" + # Verify fetch_token was called with the callback response + oauth.fetch_token.assert_called_once_with(mock_auth_response) + oauth._save_token.assert_called_once_with(mock_token) def test_authenticate_unexpected_error(self, oauth): """Test authentication failure due to unexpected error during token fetch""" @@ -246,35 +246,36 @@ def test_authenticate_unexpected_error(self, oauth): assert str(exc_info.value) == "some unhandled error" - def test_authenticate_exception_flows(self, oauth): - """Test exception handling paths in authenticate method""" - mock_auth_response = "https://localhost:8080/callback?code=test_code&state=test_state" - - oauth.get_authorization_url = Mock(return_value=("test_url", "test_state")) - oauth.is_authenticated = Mock(return_value=False) - - # Test invalid_grant flow - oauth.fetch_token = Mock(side_effect=Exception("invalid_grant")) - with ( - patch("builtins.input", return_value=mock_auth_response), - patch("webbrowser.open"), - raises(InvalidGrantException) as exc_info, - ): - oauth.authenticate() - - assert exc_info.value.status_code == 400 - assert exc_info.value.error_type == "invalid_grant" - - # Test other exception flow - oauth.fetch_token = Mock(side_effect=ValueError("other error")) - with ( - patch("builtins.input", return_value=mock_auth_response), - patch("webbrowser.open"), - raises(ValueError) as exc_info, - ): - oauth.authenticate() - - assert str(exc_info.value) == "other error" + def test_fetch_token_handles_all_exception_types(self, oauth): + """Test that fetch_token handles all exception types from ERROR_TYPE_EXCEPTIONS map""" + # Local imports + from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS + + # Get a few key error types to test (no need to test all of them) + test_error_types = [ + "expired_token", + "invalid_grant", + "invalid_client", + "insufficient_scope", + ] + + for error_type in test_error_types: + # Create a mock error with this error type in the message + mock_error = Exception(f"Error with {error_type} in the message") + mock_session = Mock() + mock_session.fetch_token.side_effect = mock_error + oauth.session = mock_session + + # Get the expected exception class for this error type + expected_exception = ERROR_TYPE_EXCEPTIONS[error_type] + + # Test that the correct exception is raised + with raises(expected_exception) as exc_info: + oauth.fetch_token("https://localhost:8080/callback?code=test") + + # Verify the exception has correct attributes + assert exc_info.value.error_type == error_type + assert exc_info.value.status_code in [400, 401] # Depending on error type # Token Fetching Tests def test_fetch_token_returns_typed_dict(self, oauth): @@ -316,30 +317,40 @@ def test_fetch_token_returns_typed_dict(self, oauth): def test_fetch_token_invalid_client(self, oauth): """Test handling of invalid client credentials""" + # Create a more realistic error message that matches what the API would return mock_session = Mock() - mock_session.fetch_token.side_effect = Exception("invalid_client") + mock_session.fetch_token.side_effect = Exception( + "invalid_client: The client credentials are invalid" + ) oauth.session = mock_session with raises(InvalidClientException) as exc_info: oauth.fetch_token("callback_url") - assert "Invalid client credentials" in str(exc_info.value) - assert exc_info.value.status_code == 401 + assert exc_info.value.status_code == 400 # Our implementation uses 400 assert exc_info.value.error_type == "invalid_client" + # The message from the API should be preserved in the exception + assert "invalid_client" in str(exc_info.value) def test_fetch_token_invalid_token(self, oauth): """Test handling of invalid authorization code""" mock_session = Mock() - mock_session.fetch_token.side_effect = Exception("invalid_token") + mock_session.fetch_token.side_effect = Exception( + "invalid_token: The token is invalid or has expired" + ) oauth.session = mock_session with raises(InvalidTokenException) as exc_info: oauth.fetch_token("callback_url") - assert "Invalid authorization code" in str(exc_info.value) assert exc_info.value.status_code == 401 assert exc_info.value.error_type == "invalid_token" + assert "invalid_token" in str(exc_info.value) + + def test_fetch_token_catches_oauth_errors(self, oauth): + """Test fetch_token correctly wraps exceptions in OAuthException""" + # Local imports + from fitbit_client.exceptions import OAuthException - def test_fetch_token_unhandled_error_logging(self, oauth): - """Test unhandled error logging in fetch_token method""" + # Create an unhandled exception type original_error = ValueError("Unhandled OAuth error") mock_session = Mock() mock_session.fetch_token.side_effect = original_error @@ -349,16 +360,19 @@ def test_fetch_token_unhandled_error_logging(self, oauth): mock_logger = Mock() oauth.logger = mock_logger - with raises(ValueError) as exc_info: + # The method should wrap the ValueError in an OAuthException + with raises(OAuthException) as exc_info: oauth.fetch_token("callback_url") + # Verify the wrapped exception has correct attributes + assert "Unhandled OAuth error" in str(exc_info.value) + assert exc_info.value.status_code == 400 + assert exc_info.value.error_type == "oauth" + # Verify the error was logged correctly - assert str(exc_info.value) == "Unhandled OAuth error" mock_logger.error.assert_called_once() log_message = mock_logger.error.call_args[0][0] assert "OAuthException" in log_message - assert "ValueError" in log_message - assert "Unhandled OAuth error" in log_message # Token Refresh Tests def test_refresh_token_returns_typed_dict(self, oauth): @@ -398,38 +412,58 @@ def test_refresh_token_returns_typed_dict(self, oauth): def test_refresh_token_expired(self, oauth): """Test handling of expired refresh token""" mock_session = Mock() - mock_session.refresh_token.side_effect = Exception("expired_token") + mock_session.refresh_token.side_effect = Exception( + "expired_token: The access token expired" + ) oauth.session = mock_session with raises(ExpiredTokenException) as exc_info: oauth.refresh_token("old_token") - assert "Access token expired" in str(exc_info.value) assert exc_info.value.status_code == 401 assert exc_info.value.error_type == "expired_token" + assert "expired_token" in str(exc_info.value) def test_refresh_token_invalid(self, oauth): """Test handling of invalid refresh token""" mock_session = Mock() - mock_session.refresh_token.side_effect = Exception("invalid_grant") + mock_session.refresh_token.side_effect = Exception( + "invalid_grant: The refresh token is invalid" + ) oauth.session = mock_session with raises(InvalidGrantException) as exc_info: oauth.refresh_token("bad_token") - assert "Refresh token invalid" in str(exc_info.value) assert exc_info.value.status_code == 400 assert exc_info.value.error_type == "invalid_grant" + assert "invalid_grant" in str(exc_info.value) + + def test_refresh_token_wraps_unexpected_errors(self, oauth): + """Test that refresh_token wraps unexpected errors in OAuthException""" + # Local imports + from fitbit_client.exceptions import OAuthException - def test_refresh_token_other_error(self, oauth): - """Test handling of unexpected error during token refresh""" mock_session = Mock() unexpected_error = ValueError("unexpected error") mock_session.refresh_token.side_effect = unexpected_error oauth.session = mock_session - with raises(ValueError) as exc_info: + # Setup logger mock to capture log message + mock_logger = Mock() + oauth.logger = mock_logger + + # The method should wrap the ValueError in an OAuthException + with raises(OAuthException) as exc_info: oauth.refresh_token("test_token") + # Verify the wrapped exception has correct attributes assert "unexpected error" in str(exc_info.value) + assert exc_info.value.status_code == 400 + assert exc_info.value.error_type == "oauth" + + # Verify the error was logged correctly + mock_logger.error.assert_called_once() + log_message = mock_logger.error.call_args[0][0] + assert "OAuthException during token refresh" in log_message def test_refresh_token_save_and_return(self, oauth): """Test that refresh_token saves and returns the new token""" diff --git a/tests/resources/active_zone_minutes/test_get_azm_timeseries_by_date.py b/tests/resources/active_zone_minutes/test_get_azm_timeseries_by_date.py index 618602c..751f53a 100644 --- a/tests/resources/active_zone_minutes/test_get_azm_timeseries_by_date.py +++ b/tests/resources/active_zone_minutes/test_get_azm_timeseries_by_date.py @@ -8,6 +8,7 @@ from pytest import raises # Local imports +from fitbit_client.exceptions import IntradayValidationException from fitbit_client.exceptions import InvalidDateException from fitbit_client.resources.constants import Period @@ -73,7 +74,7 @@ def test_get_azm_timeseries_by_date_with_user_id(azm_resource, mock_response): def test_get_azm_timeseries_by_date_invalid_period(azm_resource): - """Test that using any period other than ONE_DAY raises ValueError""" + """Test that using any period other than ONE_DAY raises IntradayValidationException""" invalid_periods = [ Period.SEVEN_DAYS, Period.THIRTY_DAYS, @@ -85,9 +86,12 @@ def test_get_azm_timeseries_by_date_invalid_period(azm_resource): Period.MAX, ] for period in invalid_periods: - with raises(ValueError) as exc_info: + with raises(IntradayValidationException) as exc_info: azm_resource.get_azm_timeseries_by_date(date="2025-02-01", period=period) assert "Only 1d period is supported for AZM time series" in str(exc_info.value) + assert exc_info.value.field_name == "period" + assert exc_info.value.allowed_values == ["1d"] + assert exc_info.value.resource_name == "active zone minutes" def test_get_azm_timeseries_by_date_invalid_date(azm_resource): diff --git a/tests/resources/activity/test_create_activity_log.py b/tests/resources/activity/test_create_activity_log.py index 10ef71a..6beeee5 100644 --- a/tests/resources/activity/test_create_activity_log.py +++ b/tests/resources/activity/test_create_activity_log.py @@ -10,6 +10,7 @@ # Local imports from fitbit_client.exceptions import InvalidDateException +from fitbit_client.exceptions import MissingParameterException # Success cases - Activity ID path @@ -111,8 +112,8 @@ def test_create_activity_log_invalid_date(activity_resource): def test_create_activity_log_missing_required_params(activity_resource): - """Test that missing required parameters raises ValueError""" - with raises(ValueError) as exc_info: + """Test that missing required parameters raises MissingParameterException""" + with raises(MissingParameterException) as exc_info: activity_resource.create_activity_log( start_time="12:00", duration_millis=3600000, date="2023-01-01" ) @@ -120,18 +121,19 @@ def test_create_activity_log_missing_required_params(activity_resource): assert "Must provide either activity_id or (activity_name and manual_calories)" in str( exc_info.value ) + assert exc_info.value.field_name == "activity_id/activity_name" def test_create_activity_log_partial_custom_params(activity_resource): - """Test that providing only activity_name without manual_calories raises ValueError""" - with raises(ValueError) as exc_info: + """Test that providing only activity_name without manual_calories raises MissingParameterException""" + with raises(MissingParameterException) as exc_info: activity_resource.create_activity_log( activity_name="Custom Yoga", start_time="12:00", duration_millis=3600000, date="2023-01-01", ) - assert "Must provide either activity_id or (activity_name and manual_calories)" in str( exc_info.value ) + assert exc_info.value.field_name == "activity_id/activity_name" diff --git a/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date.py b/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date.py index b4f8e95..c0f866f 100644 --- a/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date.py +++ b/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date.py @@ -8,7 +8,9 @@ from pytest import raises # Local imports +from fitbit_client.exceptions import IntradayValidationException from fitbit_client.exceptions import InvalidDateException +from fitbit_client.exceptions import ParameterValidationException from fitbit_client.resources.constants import Period @@ -80,18 +82,18 @@ def test_get_heartrate_timeseries_by_date_invalid_date(heartrate_resource): def test_get_heartrate_timeseries_by_date_invalid_period(heartrate_resource): """Test that error is raised for unsupported period""" - with raises(ValueError) as exc_info: + with raises(IntradayValidationException) as exc_info: heartrate_resource.get_heartrate_timeseries_by_date( date="2024-02-10", period=Period.ONE_YEAR ) error_msg = str(exc_info.value) - assert error_msg.startswith("Period must be one of: ") + assert "Period must be one of the supported values" in error_msg assert all((period in error_msg for period in ["1d", "7d", "30d", "1w", "1m"])) def test_get_heartrate_timeseries_by_date_invalid_timezone(heartrate_resource): """Test that error is raised for unsupported timezone""" - with raises(ValueError) as exc_info: + with raises(ParameterValidationException) as exc_info: heartrate_resource.get_heartrate_timeseries_by_date( date="2024-02-10", period=Period.ONE_DAY, timezone="EST" ) diff --git a/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date_range.py b/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date_range.py index 159e6ac..2eb3438 100644 --- a/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date_range.py +++ b/tests/resources/heartrate_timeseries/test_get_heartrate_timeseries_by_date_range.py @@ -10,6 +10,7 @@ # Local imports from fitbit_client.exceptions import InvalidDateException from fitbit_client.exceptions import InvalidDateRangeException +from fitbit_client.exceptions import ParameterValidationException def test_get_heartrate_timeseries_by_date_range_success(heartrate_resource, mock_response): @@ -75,9 +76,10 @@ def test_get_heartrate_timeseries_by_date_range_invalid_range(heartrate_resource def test_get_heartrate_timeseries_by_date_range_invalid_timezone(heartrate_resource): - """Test that invalid timezone raises ValueError""" - with raises(ValueError) as exc_info: + """Test that invalid timezone raises ParameterValidationException""" + with raises(ParameterValidationException) as exc_info: heartrate_resource.get_heartrate_timeseries_by_date_range( start_date="2024-02-10", end_date="2024-02-11", timezone="EST" ) assert str(exc_info.value) == "Only 'UTC' timezone is supported" + assert exc_info.value.field_name == "timezone" diff --git a/tests/resources/nutrition/test_create_food.py b/tests/resources/nutrition/test_create_food.py index b8a0d48..3b837ba 100644 --- a/tests/resources/nutrition/test_create_food.py +++ b/tests/resources/nutrition/test_create_food.py @@ -99,7 +99,6 @@ def test_create_food_calories_from_fat_must_be_integer(nutrition_resource): ) # Float instead of integer # Verify exception details - assert exc_info.value.error_type == "client_validation" assert exc_info.value.field_name == "CALORIES_FROM_FAT" assert "Calories from fat must be an integer" in str(exc_info.value) diff --git a/tests/resources/nutrition/test_create_food_goal.py b/tests/resources/nutrition/test_create_food_goal.py index e94eaf1..d153141 100644 --- a/tests/resources/nutrition/test_create_food_goal.py +++ b/tests/resources/nutrition/test_create_food_goal.py @@ -8,6 +8,7 @@ from pytest import raises # Local imports +from fitbit_client.exceptions import MissingParameterException from fitbit_client.resources.constants import FoodPlanIntensity @@ -49,7 +50,8 @@ def test_create_food_goal_with_intensity_success(nutrition_resource, mock_respon def test_create_food_goal_validation_error(nutrition_resource): - """Test that creating a food goal without required parameters raises ValueError""" - with raises(ValueError) as exc_info: + """Test that creating a food goal without required parameters raises MissingParameterException""" + with raises(MissingParameterException) as exc_info: nutrition_resource.create_food_goal() assert "Must provide either calories or intensity" in str(exc_info.value) + assert exc_info.value.field_name == "calories/intensity" diff --git a/tests/resources/nutrition/test_create_food_log.py b/tests/resources/nutrition/test_create_food_log.py index a9e20e4..312691f 100644 --- a/tests/resources/nutrition/test_create_food_log.py +++ b/tests/resources/nutrition/test_create_food_log.py @@ -240,7 +240,7 @@ def test_method(date, meal_type_id, unit_id, amount, **kwargs): def test_create_food_log_validation_error(nutrition_resource): - """Test that creating a food log without required parameters raises ValueError""" + """Test that creating a food log without required parameters raises ClientValidationException""" with raises(ClientValidationException) as exc_info: nutrition_resource.create_food_log( date="2025-02-08", meal_type_id=MealType.BREAKFAST, unit_id=147, amount=100.0 diff --git a/tests/resources/nutrition/test_update_food_log.py b/tests/resources/nutrition/test_update_food_log.py index f86eb4d..8224f2c 100644 --- a/tests/resources/nutrition/test_update_food_log.py +++ b/tests/resources/nutrition/test_update_food_log.py @@ -8,6 +8,7 @@ from pytest import raises # Local imports +from fitbit_client.exceptions import MissingParameterException from fitbit_client.resources.constants import MealType @@ -50,7 +51,8 @@ def test_update_food_log_with_calories_success(nutrition_resource, mock_response def test_update_food_log_validation_error(nutrition_resource): - """Test that updating a food log without required parameters raises ValueError""" - with raises(ValueError) as exc_info: + """Test that updating a food log without required parameters raises MissingParameterException""" + with raises(MissingParameterException) as exc_info: nutrition_resource.update_food_log(food_log_id=12345, meal_type_id=MealType.LUNCH) assert "Must provide either (unit_id and amount) or calories" in str(exc_info.value) + assert exc_info.value.field_name == "unit_id/amount/calories" diff --git a/tests/resources/sleep/test_create_sleep_goals.py b/tests/resources/sleep/test_create_sleep_goals.py index a4358b4..0d20039 100644 --- a/tests/resources/sleep/test_create_sleep_goals.py +++ b/tests/resources/sleep/test_create_sleep_goals.py @@ -7,6 +7,9 @@ # Third party imports from pytest import raises +# Local imports +from fitbit_client.exceptions import ParameterValidationException + def test_create_sleep_goals_success(sleep_resource, mock_oauth_session, mock_response_factory): """Test successful creation of sleep goal""" @@ -25,7 +28,8 @@ def test_create_sleep_goals_success(sleep_resource, mock_oauth_session, mock_res def test_create_sleep_goals_invalid_duration(sleep_resource): - """Test that negative duration raises ValueError""" - with raises(ValueError) as exc_info: + """Test that negative duration raises ParameterValidationException""" + with raises(ParameterValidationException) as exc_info: sleep_resource.create_sleep_goals(min_duration=-1) assert "min_duration must be positive" in str(exc_info.value) + assert exc_info.value.field_name == "min_duration" diff --git a/tests/resources/sleep/test_create_sleep_log.py b/tests/resources/sleep/test_create_sleep_log.py index 790c62b..245a734 100644 --- a/tests/resources/sleep/test_create_sleep_log.py +++ b/tests/resources/sleep/test_create_sleep_log.py @@ -9,6 +9,7 @@ # Local imports from fitbit_client.exceptions import InvalidDateException +from fitbit_client.exceptions import ParameterValidationException def test_create_sleep_log_success(sleep_resource, mock_oauth_session, mock_response_factory): @@ -31,10 +32,11 @@ def test_create_sleep_log_success(sleep_resource, mock_oauth_session, mock_respo def test_create_sleep_log_invalid_duration(sleep_resource): - """Test that negative duration raises ValueError""" - with raises(ValueError) as exc_info: + """Test that negative duration raises ParameterValidationException""" + with raises(ParameterValidationException) as exc_info: sleep_resource.create_sleep_log(start_time="22:00", duration_millis=-1, date="2024-02-13") assert "duration_millis must be positive" in str(exc_info.value) + assert exc_info.value.field_name == "duration_millis" def test_create_sleep_log_invalid_date(sleep_resource): diff --git a/tests/test_client.py b/tests/test_client.py index 766c5c3..8cfef2b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,6 +10,8 @@ # Local imports from fitbit_client.client import FitbitClient +from fitbit_client.exceptions import OAuthException +from fitbit_client.exceptions import SystemException @fixture @@ -42,8 +44,19 @@ def test_client_authenticate_force_new(client, mock_oauth): mock_oauth.authenticate.assert_called_once_with(force_new=True) -def test_client_authenticate_error(client, mock_oauth): - """Test authentication error handling""" - mock_oauth.authenticate.side_effect = RuntimeError("Auth failed") - with raises(RuntimeError): +def test_client_authenticate_oauth_error(client, mock_oauth): + """Test OAuth authentication error handling""" + mock_error = OAuthException(message="Auth failed", error_type="oauth", status_code=400) + mock_oauth.authenticate.side_effect = mock_error + with raises(OAuthException) as exc_info: client.authenticate() + assert "Auth failed" in str(exc_info.value) + + +def test_client_authenticate_system_error(client, mock_oauth): + """Test system error handling""" + mock_error = SystemException(message="System failure", error_type="system", status_code=500) + mock_oauth.authenticate.side_effect = mock_error + with raises(SystemException) as exc_info: + client.authenticate() + assert "System failure" in str(exc_info.value) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 404b61d..d50f568 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -22,8 +22,10 @@ from fitbit_client.exceptions import InvalidGrantException from fitbit_client.exceptions import InvalidRequestException from fitbit_client.exceptions import InvalidTokenException +from fitbit_client.exceptions import MissingParameterException from fitbit_client.exceptions import NotFoundException from fitbit_client.exceptions import OAuthException +from fitbit_client.exceptions import ParameterValidationException from fitbit_client.exceptions import RateLimitExceededException from fitbit_client.exceptions import RequestException from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS @@ -246,6 +248,42 @@ def test_intraday_validation_exception_with_resource(self): assert str(exc) == "Invalid detail level. Allowed values: 1min for heart rate" +class TestParameterValidationException: + """Test suite for ParameterValidationException""" + + def test_parameter_validation_exception_minimal(self): + """Test with minimal required parameters""" + exc = ParameterValidationException(message="Value must be positive") + assert isinstance(exc, ClientValidationException) + assert str(exc) == "Value must be positive" + assert exc.field_name is None + + def test_parameter_validation_exception_with_field(self): + """Test with field name specified""" + exc = ParameterValidationException(message="Value must be positive", field_name="duration") + assert str(exc) == "Value must be positive" + assert exc.field_name == "duration" + + +class TestMissingParameterException: + """Test suite for MissingParameterException""" + + def test_missing_parameter_exception_minimal(self): + """Test with minimal required parameters""" + exc = MissingParameterException(message="Required parameter missing") + assert isinstance(exc, ClientValidationException) + assert str(exc) == "Required parameter missing" + assert exc.field_name is None + + def test_missing_parameter_exception_with_field(self): + """Test with field name specified""" + exc = MissingParameterException( + message="Must provide either food_id or food_name", field_name="food_parameter" + ) + assert str(exc) == "Must provide either food_id or food_name" + assert exc.field_name == "food_parameter" + + class TestExceptionMappings: """Test exception mapping dictionaries""" diff --git a/tests/utils/test_date_validation.py b/tests/utils/test_date_validation.py index 89bd561..232b548 100644 --- a/tests/utils/test_date_validation.py +++ b/tests/utils/test_date_validation.py @@ -46,8 +46,6 @@ def test_validate_date_format_invalid(self): for invalid_date in invalid_dates: with raises(InvalidDateException) as exc: validate_date_format(invalid_date) - assert exc.value.status_code is None - assert exc.value.error_type == "invalid_date" assert exc.value.date_str == invalid_date assert f"Invalid date format. Expected YYYY-MM-DD, got: {invalid_date}" in str( exc.value From 5eddcf3432632fc1a0490ddb3d0e166d853d0057 Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Tue, 4 Mar 2025 06:03:26 -0500 Subject: [PATCH 2/2] fix test coverage --- fitbit_client/__init__.py,cover | 1 + fitbit_client/auth/__init__.py,cover | 1 + fitbit_client/auth/callback_handler.py,cover | 164 +++ fitbit_client/auth/callback_server.py,cover | 274 ++++ fitbit_client/auth/oauth.py,cover | 328 +++++ fitbit_client/client.py,cover | 147 ++ fitbit_client/exceptions.py,cover | 290 ++++ fitbit_client/resources/__init__.py,cover | 1 + .../resources/active_zone_minutes.py,cover | 126 ++ fitbit_client/resources/activity.py,cover | 658 +++++++++ .../resources/activity_timeseries.py,cover | 136 ++ fitbit_client/resources/base.py,cover | 498 +++++++ fitbit_client/resources/body.py,cover | 419 ++++++ .../resources/body_timeseries.py,cover | 384 +++++ .../resources/breathing_rate.py,cover | 109 ++ .../resources/cardio_fitness_score.py,cover | 106 ++ fitbit_client/resources/constants.py,cover | 281 ++++ fitbit_client/resources/device.py,cover | 205 +++ .../resources/electrocardiogram.py,cover | 103 ++ fitbit_client/resources/friends.py,cover | 104 ++ .../resources/heartrate_timeseries.py,cover | 179 +++ .../resources/heartrate_variability.py,cover | 122 ++ fitbit_client/resources/intraday.py,cover | 834 +++++++++++ .../irregular_rhythm_notifications.py,cover | 137 ++ fitbit_client/resources/nutrition.py,cover | 1241 +++++++++++++++++ .../resources/nutrition_timeseries.py,cover | 139 ++ fitbit_client/resources/sleep.py,cover | 371 +++++ fitbit_client/resources/spo2.py,cover | 126 ++ fitbit_client/resources/subscription.py,cover | 206 +++ fitbit_client/resources/temperature.py,cover | 179 +++ fitbit_client/resources/user.py,cover | 207 +++ fitbit_client/utils/__init__.py,cover | 1 + fitbit_client/utils/date_validation.py,cover | 223 +++ fitbit_client/utils/helpers.py,cover | 74 + .../utils/pagination_validation.py,cover | 126 ++ fitbit_client/utils/types.py,cover | 29 + tests/auth/test_oauth.py | 41 + 37 files changed, 8570 insertions(+) create mode 100644 fitbit_client/__init__.py,cover create mode 100644 fitbit_client/auth/__init__.py,cover create mode 100644 fitbit_client/auth/callback_handler.py,cover create mode 100644 fitbit_client/auth/callback_server.py,cover create mode 100644 fitbit_client/auth/oauth.py,cover create mode 100644 fitbit_client/client.py,cover create mode 100644 fitbit_client/exceptions.py,cover create mode 100644 fitbit_client/resources/__init__.py,cover create mode 100644 fitbit_client/resources/active_zone_minutes.py,cover create mode 100644 fitbit_client/resources/activity.py,cover create mode 100644 fitbit_client/resources/activity_timeseries.py,cover create mode 100644 fitbit_client/resources/base.py,cover create mode 100644 fitbit_client/resources/body.py,cover create mode 100644 fitbit_client/resources/body_timeseries.py,cover create mode 100644 fitbit_client/resources/breathing_rate.py,cover create mode 100644 fitbit_client/resources/cardio_fitness_score.py,cover create mode 100644 fitbit_client/resources/constants.py,cover create mode 100644 fitbit_client/resources/device.py,cover create mode 100644 fitbit_client/resources/electrocardiogram.py,cover create mode 100644 fitbit_client/resources/friends.py,cover create mode 100644 fitbit_client/resources/heartrate_timeseries.py,cover create mode 100644 fitbit_client/resources/heartrate_variability.py,cover create mode 100644 fitbit_client/resources/intraday.py,cover create mode 100644 fitbit_client/resources/irregular_rhythm_notifications.py,cover create mode 100644 fitbit_client/resources/nutrition.py,cover create mode 100644 fitbit_client/resources/nutrition_timeseries.py,cover create mode 100644 fitbit_client/resources/sleep.py,cover create mode 100644 fitbit_client/resources/spo2.py,cover create mode 100644 fitbit_client/resources/subscription.py,cover create mode 100644 fitbit_client/resources/temperature.py,cover create mode 100644 fitbit_client/resources/user.py,cover create mode 100644 fitbit_client/utils/__init__.py,cover create mode 100644 fitbit_client/utils/date_validation.py,cover create mode 100644 fitbit_client/utils/helpers.py,cover create mode 100644 fitbit_client/utils/pagination_validation.py,cover create mode 100644 fitbit_client/utils/types.py,cover diff --git a/fitbit_client/__init__.py,cover b/fitbit_client/__init__.py,cover new file mode 100644 index 0000000..93f715e --- /dev/null +++ b/fitbit_client/__init__.py,cover @@ -0,0 +1 @@ + # fitbit_client/__init__.py diff --git a/fitbit_client/auth/__init__.py,cover b/fitbit_client/auth/__init__.py,cover new file mode 100644 index 0000000..1422fc7 --- /dev/null +++ b/fitbit_client/auth/__init__.py,cover @@ -0,0 +1 @@ + # fitbit_client/auth/__init__.py diff --git a/fitbit_client/auth/callback_handler.py,cover b/fitbit_client/auth/callback_handler.py,cover new file mode 100644 index 0000000..d8cfee1 --- /dev/null +++ b/fitbit_client/auth/callback_handler.py,cover @@ -0,0 +1,164 @@ + # fitbit_client/auth/callback_handler.py + + # Standard library imports +> from http.server import BaseHTTPRequestHandler +> from http.server import HTTPServer +> from logging import Logger +> from logging import getLogger +> from socket import socket +> from typing import Any # Used only for type declarations, not in runtime code +> from typing import Callable +> from typing import Dict +> from typing import List +> from typing import Tuple +> from typing import Type +> from typing import TypeVar +> from typing import Union +> from urllib.parse import parse_qs +> from urllib.parse import urlparse + + # Local imports +> from fitbit_client.exceptions import InvalidGrantException +> from fitbit_client.exceptions import InvalidRequestException +> from fitbit_client.utils.types import JSONDict + + # Type variable for server +> T = TypeVar("T", bound=HTTPServer) + + +> class CallbackHandler(BaseHTTPRequestHandler): +> """Handle OAuth2 callback requests""" + +> logger: Logger + +> def __init__(self, *args: Any, **kwargs: Any) -> None: +> """Initialize the callback handler. + +> The signature matches BaseHTTPRequestHandler's __init__ method: +> __init__(self, request: Union[socket, Tuple[bytes, socket]], +> client_address: Tuple[str, int], +> server: HTTPServer) + +> But we use *args, **kwargs to avoid type compatibility issues with the parent class. +> """ +> self.logger = getLogger("fitbit_client.callback_handler") +> super().__init__(*args, **kwargs) + +> def parse_query_parameters(self) -> Dict[str, str]: +> """Parse and validate query parameters from callback URL + +> Returns: +> Dictionary of parsed parameters with single values + +> Raises: +> InvalidRequestException: If required parameters are missing +> InvalidGrantException: If authorization code is invalid/expired +> """ +> query_components: Dict[str, List[str]] = parse_qs(urlparse(self.path).query) +> self.logger.debug(f"Query parameters: {query_components}") + + # Check for error response +> if "error" in query_components: +> error_type: str = query_components["error"][0] +> error_desc: str = query_components.get("error_description", ["Unknown error"])[0] + +> if error_type == "invalid_grant": +> raise InvalidGrantException( +> message=error_desc, status_code=400, error_type="invalid_grant" +> ) +> else: +> raise InvalidRequestException( +> message=error_desc, status_code=400, error_type=error_type +> ) + + # Check for required parameters +> required_params: List[str] = ["code", "state"] +> missing_params: List[str] = [ +> param for param in required_params if param not in query_components +> ] +> if missing_params: +> raise InvalidRequestException( +> message=f"Missing required parameters: {', '.join(missing_params)}", +> status_code=400, +> error_type="invalid_request", +> field_name="callback_params", +> ) + + # Convert from Dict[str, List[str]] to Dict[str, str] by taking first value of each +> return {k: v[0] for k, v in query_components.items()} + +> def send_success_response(self) -> None: +> """Send successful authentication response to browser""" +> self.send_response(200) +> self.send_header("Content-Type", "text/html") +> self.end_headers() + +> response: str = """ +> +> +>

Authentication Successful!

+>

You can close this window and return to your application.

+> +> +> +> """ + +> self.wfile.write(response.encode("utf-8")) +> self.logger.debug("Sent success response to browser") + +> def send_error_response(self, error_message: str) -> None: +> """Send error response to browser""" +> self.send_response(400) +> self.send_header("Content-Type", "text/html") +> self.end_headers() + +> response: str = f""" +> +> +>

Authentication Error

+>

{error_message}

+>

You can close this window and try again.

+> +> +> +> """ + +> self.wfile.write(response.encode("utf-8")) +> self.logger.debug("Sent error response to browser") + +> def do_GET(self) -> None: +> """Process GET request and extract OAuth parameters + +> This handles the OAuth2 callback, including: +> - Parameter validation +> - Error handling +> - Success/error responses +> - Storing callback data for the server +> """ +> self.logger.debug(f"Received callback request: {self.path}") + +> try: + # Parse and validate query parameters +> self.parse_query_parameters() + + # Send success response +> self.send_success_response() + + # Store validated callback in server instance +> setattr(self.server, "last_callback", self.path) +> self.logger.debug("OAuth callback received and validated successfully") + +> except (InvalidRequestException, InvalidGrantException) as e: + # Send error response to browser +> self.send_error_response(str(e)) + # Re-raise for server to handle +> raise + +> def log_message(self, format_str: str, *args: Union[str, int, float]) -> None: +> """Override default logging to use our logger instead + +> Args: +> format_str: Format string for the log message +> args: Values to be formatted into the string +> """ +> self.logger.debug(f"Server log: {format_str % args}") diff --git a/fitbit_client/auth/callback_server.py,cover b/fitbit_client/auth/callback_server.py,cover new file mode 100644 index 0000000..b5fe7c4 --- /dev/null +++ b/fitbit_client/auth/callback_server.py,cover @@ -0,0 +1,274 @@ + # fitbit_client/auth/callback_server.py + + # Standard library imports +> from datetime import UTC +> from datetime import datetime +> from datetime import timedelta +> from http.server import HTTPServer +> from logging import getLogger +> from os import unlink +> from ssl import PROTOCOL_TLS_SERVER +> from ssl import SSLContext +> from ssl import SSLError +> from tempfile import NamedTemporaryFile +> from threading import Thread +> from time import sleep +> from time import time +> from typing import Any +> from typing import IO +> from typing import Optional +> from typing import Tuple +> from urllib.parse import urlparse + + # Third party imports +> from cryptography import x509 +> from cryptography.hazmat.primitives import hashes +> from cryptography.hazmat.primitives import serialization +> from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key +> from cryptography.x509.oid import NameOID + + # Local imports +> from fitbit_client.auth.callback_handler import CallbackHandler +> from fitbit_client.exceptions import InvalidRequestException +> from fitbit_client.exceptions import SystemException + + +> class CallbackServer: +> """Local HTTPS server to handle OAuth2 callbacks""" + +> def __init__(self, redirect_uri: str) -> None: +> """Initialize callback server + +> Args: +> redirect_uri: Complete OAuth redirect URI (must be HTTPS) + +> Raises: +> InvalidRequestException: If redirect_uri doesn't use HTTPS or is invalid +> """ +> self.logger = getLogger("fitbit_client.callback_server") +> self.logger.debug(f"Initializing callback server for {redirect_uri}") + +> parsed = urlparse(redirect_uri) + +> if parsed.scheme != "https": +> raise InvalidRequestException( +> message="Request to invalid domain: redirect_uri must use HTTPS protocol.", +> status_code=400, +> error_type="invalid_request", +> field_name="redirect_uri", +> ) + +> if not parsed.hostname: +> raise InvalidRequestException( +> message="Invalid redirect_uri parameter value", +> status_code=400, +> error_type="invalid_request", +> field_name="redirect_uri", +> ) + +> self.host: str = parsed.hostname +> self.port: int = parsed.port or 8080 +> self.server: Optional[HTTPServer] = None +> self.oauth_response: Optional[str] = None +> self.cert_file: Optional[IO[bytes]] = None +> self.key_file: Optional[IO[bytes]] = None + +> def create_handler( +> self, request: Any, client_address: Tuple[str, int], server: HTTPServer +> ) -> CallbackHandler: +> """Factory function to create CallbackHandler instances. + +> Args: +> request: The request from the client +> client_address: The client's address +> server: The HTTPServer instance + +> Returns: +> A new CallbackHandler instance +> """ +> return CallbackHandler(request, client_address, server) + +> def start(self) -> None: +> """ +> Start callback server in background thread + +> Raises: +> SystemException: If there's an error starting the server or configuring SSL +> """ +> self.logger.debug(f"Starting HTTPS server on {self.host}:{self.port}") + +> try: + # Use the factory function instead of directly passing CallbackHandler class +> self.server = HTTPServer((self.host, self.port), self.create_handler) + + # Create SSL context and certificate +> self.logger.debug("Creating SSL context and certificate") +> context = SSLContext(PROTOCOL_TLS_SERVER) + + # Generate key +> try: +> private_key = generate_private_key(public_exponent=65537, key_size=2048) +> self.logger.debug("Generated private key") +> except Exception as e: +> raise SystemException( +> message=f"Failed to generate SSL key: {str(e)}", +> status_code=500, +> error_type="system", +> ) + + # Generate certificate +> try: +> subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.host)]) +> cert = ( +> x509.CertificateBuilder() +> .subject_name(subject) +> .issuer_name(issuer) +> .public_key(private_key.public_key()) +> .serial_number(x509.random_serial_number()) +> .not_valid_before(datetime.now(UTC)) +> .not_valid_after(datetime.now(UTC) + timedelta(days=10)) +> .add_extension( +> x509.SubjectAlternativeName([x509.DNSName(self.host)]), critical=False +> ) +> .sign(private_key, hashes.SHA256()) +> ) +> self.logger.debug("Generated self-signed certificate") +> except Exception as e: +> raise SystemException( +> message=f"Failed to generate SSL certificate: {str(e)}", +> status_code=500, +> error_type="system", +> ) + + # Create temporary files for cert and key +> try: +> self.cert_file = NamedTemporaryFile(mode="wb", delete=False) +> self.key_file = NamedTemporaryFile(mode="wb", delete=False) + + # Write cert and key to temp files +> self.cert_file.write(cert.public_bytes(serialization.Encoding.PEM)) +> self.key_file.write( +> private_key.private_bytes( +> encoding=serialization.Encoding.PEM, +> format=serialization.PrivateFormat.PKCS8, +> encryption_algorithm=serialization.NoEncryption(), +> ) +> ) +> self.cert_file.close() +> self.key_file.close() +> self.logger.debug("Wrote certificate and key to temporary files") +> except Exception as e: +> raise SystemException( +> message=f"Failed to write SSL files: {str(e)}", +> status_code=500, +> error_type="system", +> ) + + # Load the cert and key into SSL context +> try: +> context.load_cert_chain(certfile=self.cert_file.name, keyfile=self.key_file.name) +> except SSLError as e: +> raise SystemException( +> message=f"Failed to load SSL certificate: {str(e)}", +> status_code=500, +> error_type="system", +> ) + + # Wrap the socket +> try: +> self.server.socket = context.wrap_socket(self.server.socket, server_side=True) +> except Exception as e: +> raise SystemException( +> message=f"Failed to configure SSL socket: {str(e)}", +> status_code=500, +> error_type="system", +> ) + +> setattr(self.server, "last_callback", None) +> self.logger.debug(f"HTTPS server started on {self.host}:{self.port}") + + # Start server in background thread +> try: +> thread = Thread(target=self.server.serve_forever, daemon=True) +> thread.start() +> self.logger.debug("Server thread started") +> except Exception as e: +> raise SystemException( +> message=f"Failed to start server thread: {str(e)}", +> status_code=500, +> error_type="system", +> ) + +> except Exception as e: + # Only catch non-SystemException exceptions here +> if not isinstance(e, SystemException): +> error_msg = f"Failed to start callback server: {str(e)}" +> self.logger.error(error_msg) +> raise SystemException(message=error_msg, status_code=500, error_type="system") +> raise + +> def wait_for_callback(self, timeout: int = 300) -> Optional[str]: +> """Wait for OAuth callback + +> Args: +> timeout: How long to wait for callback in seconds + +> Returns: +> Optional[str]: Full callback URL with auth parameters or None if timeout + +> Raises: +> SystemException: If server was not started +> InvalidRequestException: If callback times out +> """ +> if not self.server: +> raise SystemException( +> message="Server not started", status_code=500, error_type="system" +> ) + +> self.logger.debug(f"Waiting for callback (timeout: {timeout}s)") + # Wait for response with timeout +> start_time = time() +> while time() - start_time < timeout: +> if hasattr(self.server, "last_callback") and getattr(self.server, "last_callback"): +> self.oauth_response = getattr(self.server, "last_callback") +> self.logger.debug("Received callback") +> return self.oauth_response +> sleep(0.1) + +> self.logger.error("Callback wait timed out") +> raise InvalidRequestException( +> message=f"OAuth callback timed out after {timeout} seconds", +> status_code=400, +> error_type="invalid_request", +> field_name="oauth_callback", +> ) + +> def stop(self) -> None: +> """Stop callback server and clean up resources""" +> self.logger.debug("Stopping callback server") +> if self.server: +> try: +> self.server.shutdown() +> self.server.server_close() +> self.logger.debug("Server stopped") +> except Exception as e: +> self.logger.error(f"Error stopping server: {str(e)}") + + # Clean up temp files +> if self.cert_file: +> try: +> self.logger.debug(f"Removing temporary certificate file: {self.cert_file.name}") +> unlink(self.cert_file.name) +> except Exception as e: +> self.logger.warning(f"Failed to remove certificate file: {str(e)}") +> self.cert_file = None + +> if self.key_file: +> try: +> self.logger.debug(f"Removing temporary key file: {self.key_file.name}") +> unlink(self.key_file.name) +> except Exception as e: +> self.logger.warning(f"Failed to remove key file: {str(e)}") +> self.key_file = None + +> self.logger.debug("Temporary resources cleaned up") diff --git a/fitbit_client/auth/oauth.py,cover b/fitbit_client/auth/oauth.py,cover new file mode 100644 index 0000000..c64b201 --- /dev/null +++ b/fitbit_client/auth/oauth.py,cover @@ -0,0 +1,328 @@ + # fitbit_client/auth/oauth.py + + # Standard library imports +> from base64 import urlsafe_b64encode +> from datetime import datetime +> from hashlib import sha256 +> import json # importing the whole module is the only way it can be patched in tests, apparently +> from logging import getLogger +> from os import environ +> from os.path import exists +> from secrets import token_urlsafe +> from typing import List +> from typing import Optional +> from typing import Tuple +> from urllib.parse import urlparse +> from webbrowser import open as browser_open + + # Third party imports +> from requests.auth import HTTPBasicAuth +> from requests_oauthlib.oauth2_session import OAuth2Session + + # Local imports +> from fitbit_client.auth.callback_server import CallbackServer +> from fitbit_client.exceptions import ExpiredTokenException +> from fitbit_client.exceptions import InvalidClientException +> from fitbit_client.exceptions import InvalidGrantException +> from fitbit_client.exceptions import InvalidRequestException +> from fitbit_client.exceptions import InvalidTokenException +> from fitbit_client.utils.types import TokenDict + + +> class FitbitOAuth2: +> """Handles OAuth2 PKCE authentication flow for Fitbit API""" + +> AUTH_URL: str = "https://www.fitbit.com/oauth2/authorize" +> TOKEN_URL: str = "https://api.fitbit.com/oauth2/token" + +> DEFAULT_SCOPES: List[str] = [ +> "activity", +> "cardio_fitness", +> "electrocardiogram", +> "heartrate", +> "irregular_rhythm_notifications", +> "location", +> "nutrition", +> "oxygen_saturation", +> "profile", +> "respiratory_rate", +> "settings", +> "sleep", +> "social", +> "temperature", +> "weight", +> ] + +> def __init__( +> self, +> client_id: str, +> client_secret: str, +> redirect_uri: str, +> token_cache_path: str, +> use_callback_server: bool = True, +> ) -> None: +> self.logger = getLogger("fitbit_client.oauth") +> self.client_id = client_id +> self.client_secret = client_secret +> self.redirect_uri = redirect_uri +> self.use_callback_server = use_callback_server +> self.token_cache_path = token_cache_path + +> parsed = urlparse(redirect_uri) +> if parsed.scheme != "https": +> raise InvalidRequestException( +> message="This request should use https protocol.", +> status_code=400, +> error_type="invalid_request", +> field_name="redirect_uri", +> ) + +> environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + +> self.callback_server = None +> if use_callback_server: +> self.callback_server = CallbackServer(redirect_uri) + +> self.code_verifier = token_urlsafe(64) +> self.code_challenge = self._generate_code_challenge() + +> self.token = self._load_token() + +> self.session = OAuth2Session( +> client_id=self.client_id, +> redirect_uri=self.redirect_uri, +> scope=self.DEFAULT_SCOPES, +> token=self.token, +> auto_refresh_url=self.TOKEN_URL, +> auto_refresh_kwargs={"client_id": self.client_id, "client_secret": self.client_secret}, +> token_updater=self._save_token, +> ) + +> def _generate_code_challenge(self) -> str: +> """Generate PKCE code challenge from verifier using SHA-256""" +> if len(self.code_verifier) < 43 or len(self.code_verifier) > 128: +> raise InvalidRequestException( +> message="The code_verifier parameter length must be between 43 and 128", +> status_code=400, +> error_type="invalid_request", +> ) + +> challenge = sha256(self.code_verifier.encode("utf-8")).digest() +> return urlsafe_b64encode(challenge).decode("utf-8").rstrip("=") + +> def _load_token(self) -> Optional[TokenDict]: +> """Load token from file if it exists and is valid""" +> try: +> if exists(self.token_cache_path): +> with open(self.token_cache_path, "r") as f: +> token_data = json.load(f) + # Convert the loaded data to our TokenDict type +> token: TokenDict = token_data + +> expires_at = token.get("expires_at", 0) +> if expires_at > datetime.now().timestamp() + 300: # 5 min buffer +> return token + +> if token.get("refresh_token"): +> try: +> return self.refresh_token(token["refresh_token"]) +> except InvalidGrantException: + # Invalid/expired refresh token +> return None +> except json.JSONDecodeError: +> self.logger.error(f"Invalid JSON in token cache file: {self.token_cache_path}") +> return None +> except OSError as e: +! self.logger.error(f"Error reading token cache file: {self.token_cache_path}: {str(e)}") +! return None +> except Exception as e: +> self.logger.error(f"Unexpected error loading token: {e.__class__.__name__}: {str(e)}") +> return None +> return None + +> def _save_token(self, token: TokenDict) -> None: +> """Save token to file""" +> with open(self.token_cache_path, "w") as f: +> json.dump(token, f) +> self.token = token + +> def authenticate(self, force_new: bool = False) -> bool: +> """Complete authentication flow if needed + +> Args: +> force_new: Force new authentication even if valid token exists + +> Returns: +> bool: True if authenticated successfully + +> Raises: +> InvalidRequestException: If the request syntax is invalid +> InvalidClientException: If the client_id is invalid +> InvalidGrantException: If the grant_type is invalid +> InvalidTokenException: If the OAuth token is invalid +> ExpiredTokenException: If the OAuth token has expired +> OAuthException: Base class for all OAuth-related exceptions +> SystemException: If there's a system-level failure +> """ +> if not force_new and self.is_authenticated(): +> self.logger.debug("Authentication token exchange completed successfully") +> return True + + # Get authorization URL and open it in browser +> auth_url, state = self.get_authorization_url() +> browser_open(auth_url) + +> if self.use_callback_server and self.callback_server: + # Start server and wait for callback +> self.callback_server.start() +> callback_url = self.callback_server.wait_for_callback() +> if not callback_url: +> raise InvalidRequestException( +> message="Timeout waiting for OAuth callback", +> status_code=400, +> error_type="invalid_request", +> ) +> self.callback_server.stop() +> else: + # Get callback URL from user +> callback_url = input("Enter the full callback URL: ") + + # Exchange authorization code for token +> token = self.fetch_token(callback_url) +> self._save_token(token) +> return True + +> def is_authenticated(self) -> bool: +> """Check if we have valid tokens""" +> if not self.token: +> return False +> expires_at = self.token.get("expires_at", 0) +> return bool(expires_at > datetime.now().timestamp()) + +> def get_authorization_url(self) -> Tuple[str, str]: +> """Get the Fitbit authorization URL""" +> auth_url_tuple = self.session.authorization_url( +> self.AUTH_URL, code_challenge=self.code_challenge, code_challenge_method="S256" +> ) +> return (str(auth_url_tuple[0]), str(auth_url_tuple[1])) + +> def fetch_token(self, authorization_response: str) -> TokenDict: +> """Exchange authorization code for access token + +> Args: +> authorization_response: The full callback URL with authorization code + +> Returns: +> TokenDict: Dictionary containing access token and other OAuth details + +> Raises: +> InvalidClientException: If the client credentials are invalid +> InvalidTokenException: If the authorization code is invalid +> InvalidGrantException: If the authorization grant is invalid +> ExpiredTokenException: If the token has expired +> OAuthException: For other OAuth-related errors +> """ +> try: +> auth = HTTPBasicAuth(self.client_id, self.client_secret) +> token_data = self.session.fetch_token( +> self.TOKEN_URL, +> authorization_response=authorization_response, +> code_verifier=self.code_verifier, +> auth=auth, +> include_client_id=True, +> ) + # Convert to our typed dictionary +> token: TokenDict = token_data +> return token +> except Exception as e: +> error_msg = str(e).lower() + + # Use standard error mapping from ERROR_TYPE_EXCEPTIONS + # Local imports +> from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS +> from fitbit_client.exceptions import OAuthException + + # Check for known error types +> for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items(): +> if error_type in error_msg: + # Special case for client ID to mask most of it in logs +> if error_type == "invalid_client": +> self.logger.error( +> f"{exception_class.__name__}: Authentication failed " +> f"(Client ID: {self.client_id[:4]}..., Error: {str(e)})" +> ) +> else: +> self.logger.error( +> f"{exception_class.__name__}: {error_type} error during token fetch: {str(e)}" +> ) + +> raise exception_class( +> message=str(e), +> status_code=( +> 401 if "token" in error_type or error_type == "authorization" else 400 +> ), +> error_type=error_type, +> ) from e + + # If no specific error type found, use OAuthException +! self.logger.error( +! f"OAuthException during token fetch: {e.__class__.__name__}: {str(e)}" +! ) +! raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e + +> def refresh_token(self, refresh_token: str) -> TokenDict: +> """Refresh the access token + +> Args: +> refresh_token: The refresh token to use + +> Returns: +> TokenDict: Dictionary containing new access token and other OAuth details + +> Raises: +> ExpiredTokenException: If the access token has expired +> InvalidGrantException: If the refresh token is invalid +> InvalidClientException: If the client credentials are invalid +> OAuthException: For other OAuth-related errors +> """ +> try: +> auth = HTTPBasicAuth(self.client_id, self.client_secret) +> extra = { +> "client_id": self.client_id, +> "refresh_token": refresh_token, +> "grant_type": "refresh_token", +> } + +> token_data = self.session.refresh_token(self.TOKEN_URL, auth=auth, **extra) + # Convert to our typed dictionary +> token: TokenDict = token_data +> self._save_token(token) +> return token +> except Exception as e: +> error_msg = str(e).lower() + + # Use standard error mapping from ERROR_TYPE_EXCEPTIONS + # Local imports +> from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS +> from fitbit_client.exceptions import OAuthException + + # Check for known error types +> for error_type, exception_class in ERROR_TYPE_EXCEPTIONS.items(): +> if error_type in error_msg: +> self.logger.error( +> f"{exception_class.__name__}: {error_type} error during token refresh: {str(e)}" +> ) + +> raise exception_class( +> message=str(e), +> status_code=( +> 401 if "token" in error_type or error_type == "authorization" else 400 +> ), +> error_type=error_type, +> ) from e + + # If no specific error type found, use OAuthException +> self.logger.error( +> f"OAuthException during token refresh: {e.__class__.__name__}: {str(e)}" +> ) +> raise OAuthException(message=str(e), status_code=400, error_type="oauth") from e diff --git a/fitbit_client/client.py,cover b/fitbit_client/client.py,cover new file mode 100644 index 0000000..094b089 --- /dev/null +++ b/fitbit_client/client.py,cover @@ -0,0 +1,147 @@ + # fitbit_client/client.py + + # Standard library imports +> from logging import getLogger +> from urllib.parse import urlparse + + # fmt: off + # isort: off + # Auth imports +> from fitbit_client.auth.oauth import FitbitOAuth2 +> from fitbit_client.exceptions import ExpiredTokenException +> from fitbit_client.exceptions import InvalidClientException +> from fitbit_client.exceptions import InvalidGrantException +> from fitbit_client.exceptions import InvalidRequestException +> from fitbit_client.exceptions import InvalidTokenException +> from fitbit_client.exceptions import OAuthException +> from fitbit_client.exceptions import SystemException + + # Resource imports +> from fitbit_client.resources.active_zone_minutes import ActiveZoneMinutesResource +> from fitbit_client.resources.activity import ActivityResource +> from fitbit_client.resources.activity_timeseries import ActivityTimeSeriesResource +> from fitbit_client.resources.body import BodyResource +> from fitbit_client.resources.body_timeseries import BodyTimeSeriesResource +> from fitbit_client.resources.breathing_rate import BreathingRateResource +> from fitbit_client.resources.cardio_fitness_score import CardioFitnessScoreResource +> from fitbit_client.resources.device import DeviceResource +> from fitbit_client.resources.electrocardiogram import ElectrocardiogramResource +> from fitbit_client.resources.friends import FriendsResource +> from fitbit_client.resources.heartrate_timeseries import HeartrateTimeSeriesResource +> from fitbit_client.resources.heartrate_variability import HeartrateVariabilityResource +> from fitbit_client.resources.intraday import IntradayResource +> from fitbit_client.resources.irregular_rhythm_notifications import IrregularRhythmNotificationsResource +> from fitbit_client.resources.nutrition import NutritionResource +> from fitbit_client.resources.nutrition_timeseries import NutritionTimeSeriesResource +> from fitbit_client.resources.sleep import SleepResource +> from fitbit_client.resources.spo2 import SpO2Resource +> from fitbit_client.resources.subscription import SubscriptionResource +> from fitbit_client.resources.temperature import TemperatureResource +> from fitbit_client.resources.user import UserResource + # isort: on + # fmt: on + + +> class FitbitClient: +> """Main client for interacting with Fitbit API""" + +> def __init__( +> self, +> client_id: str, +> client_secret: str, +> redirect_uri: str, +> use_callback_server: bool = True, +> token_cache_path: str = "/tmp/fitbit_tokens.json", +> language: str = "en_US", +> locale: str = "en_US", +> ) -> None: +> """Initialize Fitbit client + +> Args: +> client_id: Your Fitbit API client ID +> client_secret: Your Fitbit API client secret +> redirect_uri: Complete OAuth redirect URI (e.g. "https://localhost:8080") +> use_callback_server: Whether to use local callback server +> token_cache_path: Path to file where auth tokens should be stored (default: /tmp/fitbit_tokens.json) +> language: Language for API responses +> locale: Locale for API responses +> """ +> self.logger = getLogger("fitbit_client") +> self.logger.debug("Initializing Fitbit client") + +> self.redirect_uri: str = redirect_uri +> parsed_uri = urlparse(redirect_uri) +> self.logger.debug( +> f"Using redirect URI: {redirect_uri} on {parsed_uri.hostname}:{parsed_uri.port}" +> ) + +> self.logger.debug("Initializing OAuth handler") +> self.auth: FitbitOAuth2 = FitbitOAuth2( +> client_id=client_id, +> client_secret=client_secret, +> redirect_uri=redirect_uri, +> token_cache_path=token_cache_path, +> use_callback_server=use_callback_server, +> ) + +> self.logger.debug(f"Initializing API resources with language={language}, locale={locale}") + # Initialize API resources + # fmt: off + # isort: off +> self.active_zone_minutes: ActiveZoneMinutesResource = ActiveZoneMinutesResource(self.auth.session, language=language, locale=locale) +> self.activity_timeseries: ActivityTimeSeriesResource = ActivityTimeSeriesResource(self.auth.session, language=language, locale=locale) +> self.activity: ActivityResource = ActivityResource(self.auth.session, language=language, locale=locale) +> self.body_timeseries: BodyTimeSeriesResource = BodyTimeSeriesResource(self.auth.session, language=language, locale=locale) +> self.body: BodyResource = BodyResource(self.auth.session, language=language, locale=locale) +> self.breathing_rate: BreathingRateResource = BreathingRateResource(self.auth.session, language=language, locale=locale) +> self.cardio_fitness_score: CardioFitnessScoreResource = CardioFitnessScoreResource(self.auth.session, language=language, locale=locale) +> self.device: DeviceResource = DeviceResource(self.auth.session, language=language, locale=locale) +> self.electrocardiogram: ElectrocardiogramResource = ElectrocardiogramResource(self.auth.session, language=language, locale=locale) +> self.friends: FriendsResource = FriendsResource(self.auth.session, language=language, locale=locale) +> self.heartrate_timeseries: HeartrateTimeSeriesResource = HeartrateTimeSeriesResource(self.auth.session, language=language, locale=locale) +> self.heartrate_variability: HeartrateVariabilityResource = HeartrateVariabilityResource(self.auth.session, language=language, locale=locale) +> self.intraday: IntradayResource = IntradayResource(self.auth.session, language=language, locale=locale) +> self.irregular_rhythm_notifications: IrregularRhythmNotificationsResource = IrregularRhythmNotificationsResource(self.auth.session, language=language, locale=locale) +> self.nutrition_timeseries: NutritionTimeSeriesResource = NutritionTimeSeriesResource(self.auth.session, language=language, locale=locale) +> self.nutrition: NutritionResource = NutritionResource(self.auth.session, language=language, locale=locale) +> self.sleep: SleepResource = SleepResource(self.auth.session, language=language, locale=locale) +> self.spo2: SpO2Resource = SpO2Resource(self.auth.session, language=language, locale=locale) +> self.subscription: SubscriptionResource = SubscriptionResource(self.auth.session, language=language, locale=locale) +> self.temperature: TemperatureResource = TemperatureResource(self.auth.session, language=language, locale=locale) +> self.user: UserResource = UserResource(self.auth.session, language=language, locale=locale) + # fmt: on + # isort: on +> self.logger.debug("Fitbit client initialized successfully") + + # API aliases will be re-implemented after resource methods have been refactored. + +> def authenticate(self, force_new: bool = False) -> bool: +> """ +> Authenticate with Fitbit API + +> Args: +> force_new: Force new authentication even if valid token exists + +> Returns: +> bool: True if authenticated successfully + +> Raises: +> OAuthException: Base class for all OAuth-related exceptions +> ExpiredTokenException: If the OAuth token has expired +> InvalidClientException: If the client_id is invalid +> InvalidGrantException: If the grant_type is invalid +> InvalidTokenException: If the OAuth token is invalid +> InvalidRequestException: If the request syntax is invalid +> SystemException: If there's a system-level failure during authentication +> """ +> self.logger.debug(f"Starting authentication (force_new={force_new})") +> try: +> result = self.auth.authenticate(force_new=force_new) +> self.logger.debug("Authentication successful") +> return result +> except OAuthException as e: +> self.logger.error(f"Authentication failed: {e.__class__.__name__}: {str(e)}") +> raise +> except SystemException as e: +> self.logger.error(f"System error during authentication: {str(e)}") +> raise diff --git a/fitbit_client/exceptions.py,cover b/fitbit_client/exceptions.py,cover new file mode 100644 index 0000000..35cbcf9 --- /dev/null +++ b/fitbit_client/exceptions.py,cover @@ -0,0 +1,290 @@ + # fitbit_client/exceptions.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import List +> from typing import Optional + + +> class FitbitAPIException(Exception): +> """Base exception for all Fitbit API errors""" + +> def __init__( +> self, +> message: str, +> error_type: str, +> status_code: Optional[int] = None, +> raw_response: Optional[Dict[str, Any]] = None, +> field_name: Optional[str] = None, +> ): +> self.message = message +> self.status_code = status_code +> self.error_type = error_type +> self.raw_response = raw_response +> self.field_name = field_name +> super().__init__(self.message) + + + ## OAuthExceptions + + +> class OAuthException(FitbitAPIException): +> """Superclass for all authentication flow exceptions""" + +- pass + + +> class ExpiredTokenException(OAuthException): +> """Raised when the OAuth token has expired""" + +- pass + + +> class InvalidGrantException(OAuthException): +> """Raised when the grant_type value is invalid""" + +- pass + + +> class InvalidTokenException(OAuthException): +> """Raised when the OAuth token is invalid""" + +- pass + + +> class InvalidClientException(OAuthException): +> """Raised when the client_id is invalid""" + +- pass + + + ## Request Exceptions + + +> class RequestException(FitbitAPIException): +> """Superclass for all API request exceptions""" + +- pass + + +> class InvalidRequestException(RequestException): +> """Raised when the request syntax is invalid""" + +- pass + + +> class AuthorizationException(RequestException): +> """Raised when there are authorization-related errors""" + +- pass + + +> class InsufficientPermissionsException(RequestException): +> """Raised when the application has insufficient permissions""" + +- pass + + +> class InsufficientScopeException(RequestException): +> """Raised when the application is missing a required scope""" + +- pass + + +> class NotFoundException(RequestException): +> """Raised when the requested resource does not exist""" + +- pass + + +> class RateLimitExceededException(RequestException): +> """Raised when the application hits rate limiting quotas""" + +- pass + + +> class SystemException(RequestException): +> """Raised when there's a system-level failure""" + +- pass + + +> class ValidationException(RequestException): +> """Raised when a request parameter is invalid or missing""" + +- pass + + + ## PreRequestValidaton Exceptions + + +> class ClientValidationException(ValueError): +> """Superclass for validations that take place before making any API request. + +> These exceptions indicate that input validation failed locally, without making +> any network requests. This helps preserve API rate limits and gives more specific +> error information than would be available from the API response.""" + +> def __init__(self, message: str, field_name: Optional[str] = None): +> """Initialize client validation exception. + +> Args: +> message: Human-readable error message +> field_name: Optional name of the invalid field +> """ +> self.message = message +> self.field_name = field_name +> super().__init__(self.message) + + +> class InvalidDateException(ClientValidationException): +> """Raised when a date string is not in the correct format or not a valid calendar date""" + +> def __init__( +> self, date_str: str, field_name: Optional[str] = None, message: Optional[str] = None +> ): +> """Initialize invalid date exception. + +> Args: +> date_str: The invalid date string +> field_name: Optional name of the date field +> message: Optional custom error message. If not provided, a default message is generated. +> """ +> super().__init__( +> message=message or f"Invalid date format. Expected YYYY-MM-DD, got: {date_str}", +> field_name=field_name, +> ) +> self.date_str = date_str + + +> class InvalidDateRangeException(ClientValidationException): +> """Raised when a date range is invalid (e.g., end before start, exceeds max days)""" + +> def __init__( +> self, +> start_date: str, +> end_date: str, +> reason: str, +> max_days: Optional[int] = None, +> resource_name: Optional[str] = None, +> ): +> """Initialize invalid date range exception. + +> Args: +> start_date: The start date of the invalid range +> end_date: The end date of the invalid range +> reason: Specific reason why the date range is invalid +> max_days: Optional maximum number of days allowed for this request +> resource_name: Optional resource or endpoint name for context +> """ + # Use the provided reason directly - don't override it +> message = f"Invalid date range: {reason}" + +> super().__init__(message=message, field_name="date_range") +> self.start_date = start_date +> self.end_date = end_date +> self.max_days = max_days +> self.resource_name = resource_name + + +> class PaginationException(ClientValidationException): +> """Raised when pagination-related parameters are invalid""" + +> def __init__(self, message: str, field_name: Optional[str] = None): +> """Initialize pagination validation exception + +> Args: +> message: Error message describing the validation failure +> field_name: Optional name of the invalid field +> """ +> super().__init__(message=message, field_name=field_name) + + +> class IntradayValidationException(ClientValidationException): +> """Raised when intraday request parameters are invalid""" + +> def __init__( +> self, +> message: str, +> field_name: str, +> allowed_values: Optional[List[str]] = None, +> resource_name: Optional[str] = None, +> ): +> """Initialize intraday validation exception + +> Args: +> message: Error message +> field_name: Name of the invalid field +> allowed_values: Optional list of valid values +> resource_name: Optional name of the resource/endpoint +> """ +> error_msg = message +> if allowed_values: +> error_msg = f"{message}. Allowed values: {', '.join(sorted(allowed_values))}" +> if resource_name: +> error_msg = f"{error_msg} for {resource_name}" + +> super().__init__(message=error_msg, field_name=field_name) +> self.allowed_values = allowed_values +> self.resource_name = resource_name + + +> class ParameterValidationException(ClientValidationException): +> """Raised when a parameter value is invalid (e.g., negative when positive required)""" + +> def __init__(self, message: str, field_name: Optional[str] = None): +> """Initialize parameter validation exception + +> Args: +> message: Error message describing the validation failure +> field_name: Optional name of the invalid field +> """ +> super().__init__(message=message, field_name=field_name) + + +> class MissingParameterException(ClientValidationException): +> """Raised when required parameters are missing or parameter combinations are invalid""" + +> def __init__(self, message: str, field_name: Optional[str] = None): +> """Initialize missing parameter exception + +> Args: +> message: Error message describing the validation failure +> field_name: Optional name of the invalid or missing field +> """ +> super().__init__(message=message, field_name=field_name) + + + # Map HTTP status codes to exception classes +> STATUS_CODE_EXCEPTIONS = { +> 400: InvalidRequestException, +> 401: AuthorizationException, +> 403: InsufficientPermissionsException, +> 404: NotFoundException, +> 409: InvalidRequestException, +> 429: RateLimitExceededException, +> 500: SystemException, +> 502: SystemException, +> 503: SystemException, +> 504: SystemException, +> } + + # Map fitbit error types to exception classes. The keys match the `errorType`s listed here: + # https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/error-handling/#types-of-errors + # This is elegant and efficient, but may take some time to understand! +> ERROR_TYPE_EXCEPTIONS = { +> "authorization": AuthorizationException, +> "expired_token": ExpiredTokenException, +> "insufficient_permissions": InsufficientPermissionsException, +> "insufficient_scope": InsufficientScopeException, +> "invalid_client": InvalidClientException, +> "invalid_grant": InvalidGrantException, +> "invalid_request": InvalidRequestException, +> "invalid_token": InvalidTokenException, +> "not_found": NotFoundException, +> "oauth": OAuthException, +> "request": RequestException, +> "system": SystemException, +> "validation": ValidationException, +> } diff --git a/fitbit_client/resources/__init__.py,cover b/fitbit_client/resources/__init__.py,cover new file mode 100644 index 0000000..e99df2f --- /dev/null +++ b/fitbit_client/resources/__init__.py,cover @@ -0,0 +1 @@ + # fitbit_client/resources/__init__.py diff --git a/fitbit_client/resources/active_zone_minutes.py,cover b/fitbit_client/resources/active_zone_minutes.py,cover new file mode 100644 index 0000000..de409bb --- /dev/null +++ b/fitbit_client/resources/active_zone_minutes.py,cover @@ -0,0 +1,126 @@ + # fitbit_client/resources/active_zone_minutes.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import IntradayValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import Period +> 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 + + +> class ActiveZoneMinutesResource(BaseResource): +> """Provides access to Fitbit Active Zone Minutes (AZM) API for heart rate-based activity metrics. + +> This resource handles endpoints for retrieving Active Zone Minutes (AZM) data, which measures +> the time users spend in target heart rate zones during exercise or daily activities. AZM +> is a scientifically-validated way to track activity intensity based on personalized heart +> rate zones rather than just steps. + +> Different zones contribute differently to the total AZM count: +> - Fat Burn zone: 1 minute = 1 AZM (moderate intensity) +> - Cardio zone: 1 minute = 2 AZM (high intensity) +> - Peak zone: 1 minute = 2 AZM (maximum effort) + +> API Reference: https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/ + +> Required Scopes: +> - activity (for all AZM endpoints) + +> Note: +> - Heart rate zones are personalized based on the user's resting heart rate and age +> - The American Heart Association recommends 150 minutes of moderate (Fat Burn) or +> 75 minutes of vigorous (Cardio/Peak) activity per week +> - AZM data is available from the date the user first set up their Fitbit device +> - Historical data older than 3 years may not be available through the API +> - Not all Fitbit devices support AZM tracking (requires heart rate monitoring) +> - The date range endpoints are useful for analyzing weekly and monthly AZM totals +> """ + +> @validate_date_param() +> def get_azm_timeseries_by_date( +> self, date: str, period: Period = Period.ONE_DAY, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns Active Zone Minutes time series data for a period ending on the specified date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/get-azm-timeseries-by-date/ + +> Args: +> date: The end date of the period in YYYY-MM-DD format or 'today' +> period: The range for which data will be returned. Only Period.ONE_DAY (1d) is supported. +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Daily Active Zone Minutes data + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If period is not Period.ONE_DAY +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Only Period.ONE_DAY (1d) is currently supported by the Fitbit API +> - activeZoneMinutes is the sum total of all zone minutes with cardio and peak +> minutes counting double (fatBurn + (cardio × 2) + (peak × 2)) +> - Fat burn zone is typically 50-69% of max heart rate (moderate intensity) +> - Cardio zone is typically 70-84% of max heart rate (high intensity) +> - Peak zone is typically 85%+ of max heart rate (maximum effort) +> - Days with no AZM data will show all metrics as zero +> """ +> if period != Period.ONE_DAY: +> raise IntradayValidationException( +> message="Only 1d period is supported for AZM time series", +> field_name="period", +> allowed_values=[Period.ONE_DAY.value], +> resource_name="active zone minutes", +> ) + +> result = self._make_request( +> f"activities/active-zone-minutes/date/{date}/{period.value}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=1095, resource_name="AZM time series") +> def get_azm_timeseries_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns Active Zone Minutes time series data for a specified date range. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/get-azm-timeseries-by-interval/ + +> Args: +> start_date: The start date in YYYY-MM-DD format or 'today' +> end_date: The end date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Daily Active Zone Minutes data for each date in the range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 1095 days + +> Note: +> - Maximum date range is 1095 days (approximately 3 years) +> - Each day's entry includes separate counts for each heart rate zone +> - activeZoneMinutes is the total AZM with cardio and peak minutes counting double +> - This endpoint is useful for calculating weekly or monthly AZM totals +> - Days with no AZM data will have all metrics as zero +> - Active Zone Minutes does not support subscription notifications (webhooks), +> but can be queried after activity notifications arrive +> - Weekly AZM goals can be tracked by summing 7 consecutive days of data +> """ +> result = self._make_request( +> f"activities/active-zone-minutes/date/{start_date}/{end_date}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/activity.py,cover b/fitbit_client/resources/activity.py,cover new file mode 100644 index 0000000..4ec96e0 --- /dev/null +++ b/fitbit_client/resources/activity.py,cover @@ -0,0 +1,658 @@ + # fitbit_client/resources/activity.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import Never +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import MissingParameterException +> from fitbit_client.exceptions import ValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import ActivityGoalPeriod +> from fitbit_client.resources.constants import ActivityGoalType +> from fitbit_client.resources.constants import SortDirection +> 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 JSONList + + +> class ActivityResource(BaseResource): +> """Provides access to Fitbit Activity API for managing user activities and goals. + +> This resource handles endpoints for recording, retrieving, and managing various +> aspects of user fitness activities including activity logs, goals, favorites, +> and lifetime statistics. It supports creating and deleting activity records, +> managing activity goals, and retrieving detailed activity information. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/ + +> Required Scopes: +> - activity (for most activity endpoints) +> - location (additionally required for get_activity_tcx) + +> Note: +> - Activity records include steps, distance, calories, active minutes, and other metrics +> - Activity logs can be created manually or automatically by Fitbit devices +> - Goals can be set on a daily or weekly basis for various activity metrics +> - Lifetime statistics track cumulative totals since the user's account creation +> - Activity types are categorized by intensity level and metabolic equivalent (MET) +> - Favorite activities can be saved for quick access when logging manual activities +> - TCX files (Training Center XML) provide detailed GPS data for activities with location tracking +> """ + +> def create_activity_goals( +> self, +> period: ActivityGoalPeriod, +> type: ActivityGoalType, +> value: int, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates or updates a user's daily or weekly activity goal. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/create-activity-goal/ + +> Args: +> period: Goal period (ActivityGoalPeriod.DAILY or ActivityGoalPeriod.WEEKLY) +> type: Goal type from ActivityGoalType enum (e.g., ActivityGoalType.STEPS, +> ActivityGoalType.FLOORS, ActivityGoalType.ACTIVE_MINUTES) +> value: Target value for the goal (must be positive) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Goal object containing the updated activity goals + +> Raises: +> fitbit_client.exceptions.ValidationException: If value is not a positive integer + +> Note: +> - This endpoint uses units that correspond to the Accept-Language header provided +> - Setting a new goal will override any previously set goal of the same type and period +> - The response includes all current goals for the specified period, not just +> the one being updated +> - Daily goals: typically steps, floors, distance, calories, active minutes +> - Weekly goals: typically steps, floors, distance, active minutes +> - Not all goal types are available for both periods (e.g., calories is daily only) +> - Goal progress can be tracked using the daily activity summary endpoints +> """ +> if value <= 0: +> raise ValidationException( +> message="Goal value must be positive", +> status_code=400, +> error_type="validation", +> field_name="value", +> ) + +> params = {"type": type.value, "value": value} +> result = self._make_request( +> f"activities/goals/{period.value}.json", +> params=params, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) + +> create_activity_goal = create_activity_goals # alias to match docs + +> @validate_date_param(field_name="date") +> def create_activity_log( +> self, +> activity_id: Optional[int] = None, +> activity_name: Optional[str] = None, +> manual_calories: Optional[int] = None, +> start_time: str = "", +> duration_millis: int = 0, +> date: str = "", +> distance: Optional[float] = None, +> distance_unit: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Records an activity to the user's activity log. + +> This endpoint can be used in two ways: +> 1. Log a predefined activity by specifying activity_id +> 2. Log a custom activity by specifying activity_name and manual_calories + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/create-activity-log/ + +> Args: +> activity_id: ID of a predefined activity (get IDs from get_activity_type endpoint) +> activity_name: Name for a custom activity (required if activity_id is not provided) +> manual_calories: Calories burned (required when logging custom activity) +> start_time: Activity start time in 24-hour format (HH:mm) +> duration_millis: Duration in milliseconds +> date: Log date in YYYY-MM-DD format or 'today' +> distance: Optional distance value (required for some activity types) +> distance_unit: Optional unit for distance ('steps', 'miles', 'km') +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The created activity log entry with details of the recorded activity + +> Raises: +> fitbit_client.exceptions.MissingParameterException: If neither activity_id nor activity_name/manual_calories pair is provided +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If required parameters are missing + +> Note: +> - You must provide either activity_id OR both activity_name and manual_calories +> - Some activities (like running or cycling) require a distance value +> - The activity will be added to the user's activity history and count toward daily goals +> - Calories and steps in the response may be estimated based on activity type and duration +> - Activity types can be found using get_activity_type and get_frequent_activities endpoints +> - Duration should be in milliseconds (e.g., 30 minutes = 1800000) +> - Start time should be in 24-hour format (e.g., "14:30" for 2:30 PM) +> """ +> if activity_id: +> params = { +> "activityId": activity_id, +> "startTime": start_time, +> "durationMillis": duration_millis, +> "date": date, +> } +> if distance is not None: +> params["distance"] = distance +> if distance_unit: +> params["distanceUnit"] = distance_unit +> elif activity_name and manual_calories: +> params = { +> "activityName": activity_name, +> "manualCalories": manual_calories, +> "startTime": start_time, +> "durationMillis": duration_millis, +> "date": date, +> } +> else: +> raise MissingParameterException( +> message="Must provide either activity_id or (activity_name and manual_calories)", +> field_name="activity_id/activity_name", +> ) + +> result = self._make_request( +> "activities.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="before_date") +> @validate_date_param(field_name="after_date") +> @validate_pagination_params(max_limit=100) +> def get_activity_log_list( +> self, +> before_date: Optional[str] = None, +> after_date: Optional[str] = None, +> sort: SortDirection = SortDirection.DESCENDING, +> limit: int = 100, +> offset: int = 0, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns a list of user's activity log entries before or after a given day. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-activity-log-list/ + +> Args: +> before_date: Return entries before this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> after_date: Return entries after this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> sort: Sort order - must use SortDirection.ASCENDING with after_date and +> SortDirection.DESCENDING with before_date (default: DESCENDING) +> limit: Number of records to return (max 100, default: 100) +> offset: Offset for pagination (only 0 is reliably supported) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity logs matching the criteria with pagination information + +> Raises: +> fitbit_client.exceptions.PaginationException: If neither before_date nor after_date is specified +> fitbit_client.exceptions.PaginationException: If limit exceeds 100 or sort direction is invalid +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Either before_date or after_date must be specified, but not both +> - The offset parameter only reliably supports 0; use the "next" URL in the +> pagination response to iterate through results +> - Includes both manual and automatic activity entries +> - Each activity entry contains detailed information about the activity, including +> duration, calories, heart rate (if available), steps, and other metrics +> - Activities are categorized based on Fitbit's internal activity type system +> - 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} +> if before_date: +> params["beforeDate"] = before_date +> if after_date: +> params["afterDate"] = after_date + +> result = self._make_request( +> "activities/list.json", params=params, user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> def create_favorite_activity( +> self, activity_id: int, user_id: str = "-", debug: bool = False +> ) -> Dict[Never, Never]: +> """Adds an activity to the user's list of favorite activities. + +> Favorite activities appear in a special section of the Fitbit app and website, +> making them easier to access when logging manual activities. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/create-favorite-activity/ + +> Args: +> activity_id: ID of the activity to favorite (get IDs from get_activity_type endpoint) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> Dict[Never, Never]: Empty dictionary on success, with HTTP 201 status code + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If activity_id is invalid +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the user + +> Note: +> - Favorites are used to quickly access common activities when logging manually +> - Activity IDs can be obtained from get_activity_type or get_frequent_activities endpoints +> - Users can have multiple favorite activities +> - Favorites are displayed prominently in the Fitbit app's manual activity logging UI +> - To retrieve the list of favorites, use the get_favorite_activities endpoint +> """ +> result = self._make_request( +> f"activities/favorite/{activity_id}.json", +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(Dict[Never, Never], result) + +> def delete_activity_log( +> self, activity_log_id: int, user_id: str = "-", debug: bool = False +> ) -> Dict[Never, Never]: +> """Deletes a specific activity log entry from the user's activity history. + +> This endpoint permanently removes an activity from the user's activity history. +> Once deleted, the activity will no longer contribute to the user's daily totals +> or achievements. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/delete-activity-log/ + +> Args: +> activity_log_id: ID of the activity log to delete (obtain from get_activity_log_list) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> Dict[Never, Never]: Empty dictionary on success, with HTTP 204 status code + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If activity_log_id is invalid +> fitbit_client.exceptions.NotFoundException: If the activity log doesn't exist +> fitbit_client.exceptions.AuthorizationException: If not authorized to delete this activity + +> Note: +> - Only manually logged activities can be deleted +> - Automatic activities detected by Fitbit devices cannot be deleted +> - Activity log IDs can be obtained from the get_activity_log_list endpoint +> - Deleting an activity permanently removes it from the user's history +> - Deletion immediately affects daily totals, goals, and achievements +> - The deletion cannot be undone +> """ +> result = self._make_request( +> f"activities/{activity_log_id}.json", user_id=user_id, http_method="DELETE", debug=debug +> ) +> return cast(Dict[Never, Never], result) + +> def delete_favorite_activity( +> self, activity_id: int, user_id: str = "-", debug: bool = False +> ) -> None: +> """Removes an activity from the user's list of favorite activities. + +> This endpoint unfavorites a previously favorited activity. The activity will +> still be available for logging but will no longer appear in the favorites list. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/delete-favorite-activity/ + +> Args: +> activity_id: ID of the activity to unfavorite +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: Returns None on success with HTTP 204 status code + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If activity_id is invalid +> fitbit_client.exceptions.NotFoundException: If the activity is not in favorites list +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the user + +> Note: +> - Removing a favorite doesn't delete the activity type, just removes it from favorites +> - To get the list of current favorites, use the get_favorite_activities endpoint +> - Activity IDs can be obtained from the get_favorite_activities response +> - Unfavoriting an activity only affects the UI display in the Fitbit app +> """ +> result = self._make_request( +> f"activities/favorite/{activity_id}.json", +> user_id=user_id, +> http_method="DELETE", +> debug=debug, +> ) +> return cast(None, result) + +> def get_activity_goals( +> self, period: ActivityGoalPeriod, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns the user's current activity goals for the specified period. + +> This endpoint retrieves the user's activity goals which are targets for steps, +> distance, floors, active minutes, and calories that the user aims to achieve +> within the specified time period (daily or weekly). + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-activity-goals/ + +> Args: +> period: Goal period - either ActivityGoalPeriod.DAILY or ActivityGoalPeriod.WEEKLY +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The current activity goals containing targets for metrics like steps, +> distance, floors, active minutes, and calories + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If period is invalid +> fitbit_client.exceptions.AuthorizationException: If not authorized to access this user's data + +> Note: +> - Daily and weekly goals may have different available metrics +> - Daily goals typically include: steps, floors, distance, active minutes, calories +> - Weekly goals typically include: steps, floors, distance, active minutes (no calories) +> - Units (miles/km) depend on the user's account settings +> - Goals can be updated using the create_activity_goals endpoint +> - The response will only include goals that have been set for the specified period +> """ +> result = self._make_request( +> f"activities/goals/{period.value}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def get_daily_activity_summary( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns a summary of the user's activities for a specific date. + +> This endpoint provides a comprehensive summary of all activity metrics for the specified +> date, including activity logs, goals, and daily totals for steps, distance, calories, and +> active minutes. It serves as a convenient way to get a complete picture of a user's +> activity for a single day. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-daily-activity-summary/ + +> Args: +> date: Date to get summary for (YYYY-MM-DD or 'today') +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity summary for the specified date containing logged activities, +> daily goals, and summary metrics (steps, distance, calories, minutes at +> different activity levels) + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> The response includes data in the unit system specified by the Accept-Language header. +> Daily summary data for elevation (elevation, floors) is only included for users with +> a device that has an altimeter. Goals are included only for today and up to 21 days +> in the past. The goals section will only include goals that have been set by the user. +> Active minutes include veryActiveMinutes, fairlyActiveMinutes, and lightlyActiveMinutes. +> """ +> result = self._make_request(f"activities/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_activity_type(self, activity_id: int, debug: bool = False) -> JSONDict: +> """Returns the details of a single activity type from Fitbit's activity database. + +> This endpoint retrieves information about a specific activity type including its name, +> description, and MET (Metabolic Equivalent of Task) value. Activity types are +> standardized categories like "Running", "Swimming", or "Yoga" that can be used +> when logging activities. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-activity-type/ + +> Args: +> activity_id: ID of the activity type to retrieve +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity type details including name, description, MET values, and +> different intensity levels (light, moderate, vigorous) + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If activity_id is invalid +> fitbit_client.exceptions.NotFoundException: If the activity type doesn't exist + +> Note: +> - This endpoint doesn't require a user_id as it accesses the global activity database +> - MET values represent the energy cost of activities (higher values = more intense) +> - Activity types are used when logging manual activities via create_activity_log +> - To find activity IDs, use get_all_activity_types or get_frequent_activities +> - The hasSpeed field indicates whether the activity supports distance tracking +> - Activity levels (Light, Moderate, Vigorous) represent intensity variations +> """ +> result = self._make_request( +> f"activities/{activity_id}.json", requires_user_id=False, debug=debug +> ) +> return cast(JSONDict, result) + +> def get_all_activity_types(self, debug: bool = False) -> JSONDict: +> """Returns the complete list of all available activity types in Fitbit's database. + +> This endpoint retrieves a comprehensive list of standardized activity types that +> can be used when logging manual activities. Each activity includes its ID, name, +> description, and MET (Metabolic Equivalent of Task) values. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-all-activity-types/ + +> Args: +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Complete list of activity types organized by categories (Cardio, Sports, etc.), +> with each activity containing its ID, name, description, and MET value + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the API + +> Note: +> - This endpoint doesn't require a user_id as it accesses the global activity database +> - Activities are organized into categories (e.g., Cardio, Sports, Water Activities) +> - The response can be large as it contains all available activities +> - Use the activity IDs from this response when calling create_activity_log +> - For a more manageable list, consider using get_frequent_activities instead +> - MET values indicate intensity (higher values = more intense activity) +> """ +> result = self._make_request("activities.json", requires_user_id=False, debug=debug) +> return cast(JSONDict, result) + +> def get_favorite_activities(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Returns the list of activities that the user has marked as favorites. + +> Favorite activities are those the user has explicitly marked for quick access +> when manually logging activities. These appear in a dedicated section of the +> activity logging interface in the Fitbit app. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-favorite-activities/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of favorite activities with details including activity ID, name, +> description, MET value, and when the activity was added as a favorite + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the user's data + +> Note: +> - Favorites are used for quick access when logging manual activities +> - Activities can be added to favorites using create_favorite_activity +> - Activities can be removed from favorites using delete_favorite_activity +> - The dateAdded field shows when the activity was marked as favorite +> - The calories field shows an estimate based on the activity's MET value +> - If the user has no favorites, an empty array is returned +> """ +> result = self._make_request("activities/favorite.json", user_id=user_id, debug=debug) +> return cast(JSONList, result) + +> def get_frequent_activities(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Returns the list of activities that the user logs most frequently. + +> This endpoint provides a personalized list of activities based on the user's +> activity logging history. It helps provide quick access to activities the user +> regularly logs, even if they're not explicitly marked as favorites. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-frequent-activities/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of frequently logged activities with details including activity ID, +> name, description, MET value, and typical metrics like duration and distance + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the user's data + +> Note: +> - This list is automatically generated based on the user's activity logging patterns +> - Unlike favorites, users cannot directly add or remove activities from this list +> - Activities with most logged instances appear in this list +> - The dateAdded field shows when the activity was most recently logged +> - If the user has no activity history, an empty array is returned +> - This list is a good source of relevant activity IDs for create_activity_log +> """ +> result = self._make_request("activities/frequent.json", user_id=user_id, debug=debug) +> return cast(JSONList, result) + +> def get_recent_activity_types(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Returns the list of activities that the user has logged recently. + +> This endpoint retrieves activities that the user has manually logged in the +> recent past, sorted by most recent first. It provides a chronological view +> of the user's activity logging history. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-recent-activity-types/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of recently logged activities with details including activity ID, name, +> description, MET value, and when each activity was logged + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access the user's data + +> Note: +> - Activities are listed in reverse chronological order (newest first) +> - Only manually logged activities appear in this list +> - The dateAdded field shows when the activity was logged +> - If the user has no recent activity logs, an empty array is returned +> - This list differs from get_activity_log_list which shows actual activity instances +> - Unlike favorites, this list is purely historical and not for quick access +> """ +> result = self._make_request("activities/recent.json", user_id=user_id, debug=debug) +> return cast(JSONList, result) + +> def get_lifetime_stats(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns the user's lifetime activity statistics and personal records. + +> This endpoint provides cumulative totals of steps, distance, floors, and active minutes, +> as well as personal activity records like "most steps in one day". + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-lifetime-stats/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Lifetime statistics containing cumulative totals (steps, distance, floors) +> and personal records (best days) for various activity metrics, divided into +> "total" (all activities) and "tracker" (device-tracked only) categories + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access this user's data + +> Note: +> - "Total" includes manually logged activities, while "tracker" only includes device-tracked data +> - A value of -1 indicates that the metric is not available +> - The "best" section contains personal records with dates and values +> - Units (miles/km) depend on the user's account settings +> - Lifetime stats accumulate from the date the user created their Fitbit account +> - Stats are updated in near real-time as new activities are recorded +> """ +> result = self._make_request("activities.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_activity_tcx( +> self, +> log_id: int, +> include_partial_tcx: bool = False, +> user_id: str = "-", +> debug: bool = False, +> ) -> str: +> """Returns the TCX (Training Center XML) data for a specific activity log. + +> TCX (Training Center XML) is a data exchange format developed by Garmin that contains +> detailed GPS coordinates, heart rate data, lap information, and other metrics recorded +> during GPS-tracked activities like running, cycling, or walking. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity/get-activity-tcx/ + +> Args: +> log_id: ID of the activity log to retrieve (obtain from get_activity_log_list) +> include_partial_tcx: Include TCX points even when GPS data is not available (default: False) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> str: Raw XML string containing TCX data in Training Center XML format + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If log_id is invalid +> fitbit_client.exceptions.NotFoundException: If the activity log doesn't exist +> fitbit_client.exceptions.InsufficientScopeException: If location scope is not authorized + +> Note: +> - Requires both 'activity' and 'location' OAuth scopes to be authorized +> - The log must be from a GPS-tracked activity (e.g., running, cycling with GPS enabled) +> - TCX data includes timestamps, GPS coordinates, elevation, heart rate, and lap data +> - TCX files can be imported into third-party fitness analysis tools +> - Setting include_partial_tcx=True will include points even if GPS signal was lost +> - 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 +> result = self._make_request( +> f"activities/{log_id}.tcx", params=params, user_id=user_id, debug=debug +> ) +> return cast(str, result) diff --git a/fitbit_client/resources/activity_timeseries.py,cover b/fitbit_client/resources/activity_timeseries.py,cover new file mode 100644 index 0000000..9b09d9e --- /dev/null +++ b/fitbit_client/resources/activity_timeseries.py,cover @@ -0,0 +1,136 @@ + # fitbit_client/resources/activity_timeseries.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import ActivityTimeSeriesPath +> from fitbit_client.resources.constants import Period +> 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 + + +> class ActivityTimeSeriesResource(BaseResource): +> """Provides access to Fitbit Activity Time Series API for retrieving historical activity data. + +> This resource handles endpoints for retrieving time series data for various activity metrics +> such as steps, distance, calories, active minutes, and floors over specified time periods. +> Time series data is useful for analyzing trends, creating visualizations, and tracking +> progress over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity-timeseries/ + +> Required Scopes: +> - activity (for all activity time series endpoints) + +> Note: +> - Time series data is available from the date the user created their Fitbit account +> - Data is organized by date with one data point per day +> - Various activity metrics are available including steps, distance, floors, calories, etc. +> - Historical data can be accessed either by period (e.g., 1d, 7d, 30d) or date range +> - Maximum date ranges vary by resource type (most allow ~3 years of historical data) +> - For more granular intraday data, see the Intraday resource +> """ + +> @validate_date_param() +> def get_activity_timeseries_by_date( +> self, +> resource_path: ActivityTimeSeriesPath, +> date: str, +> period: Period, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns activity time series data for a period ending on the specified date. + +> This endpoint provides historical activity data for a specific time period (e.g., 1d, 7d, 30d) +> ending on the specified date. It's useful for retrieving recent activity history with a +> consistent timeframe. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity-timeseries/get-activity-timeseries-by-date/ + +> Args: +> resource_path: The resource path from ActivityTimeSeriesPath enum (e.g., +> ActivityTimeSeriesPath.STEPS, ActivityTimeSeriesPath.DISTANCE) +> date: The end date in YYYY-MM-DD format or "today" +> period: Time period to get data for (e.g., Period.ONE_DAY, Period.SEVEN_DAYS, Period.THIRTY_DAYS) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity time series data for the specified activity metric and time period + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidRequestException: If resource_path or period is invalid + +> Note: +> - The response format varies slightly depending on the resource_path +> - All values are returned as strings and need to be converted to appropriate types +> - For numeric resources like steps, values should be converted to integers +> - The number of data points equals the number of days in the period +> - Data is returned in ascending date order (oldest first) +> - If no data exists for a particular day, the value may be "0" or the day may be omitted +> - Period options include 1d, 7d, 30d, 1w, 1m, 3m, 6m, 1y +> """ +> result = self._make_request( +> f"activities/{resource_path.value}/date/{date}/{period.value}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=1095, resource_name="activity time series") +> def get_activity_timeseries_by_date_range( +> self, +> resource_path: ActivityTimeSeriesPath, +> start_date: str, +> end_date: str, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns activity time series data for a specified date range. + +> This endpoint provides historical activity data for a custom date range between two +> specified dates. It's useful for analyzing activity patterns over specific time periods +> or generating custom reports. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity-timeseries/get-activity-timeseries-by-date-range/ + +> Args: +> resource_path: The resource path from ActivityTimeSeriesPath enum (e.g., +> ActivityTimeSeriesPath.STEPS, ActivityTimeSeriesPath.CALORIES) +> start_date: The start date in YYYY-MM-DD format or "today" +> end_date: The end date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity time series data for the specified activity metric and date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds maximum allowed days +> fitbit_client.exceptions.InvalidRequestException: If resource_path is invalid + +> Note: +> - Maximum date ranges vary by resource type: +> * ActivityTimeSeriesPath.ACTIVITY_CALORIES: 30 days maximum +> * Most other resources: 1095 days (~3 years) maximum +> - The response format varies slightly depending on the resource_path +> - All values are returned as strings and need to be converted to appropriate types +> - Data is returned in ascending date order (oldest first) +> - The date range is inclusive of both start_date and end_date +> - For longer date ranges, consider making multiple requests with smaller ranges +> - If no data exists for a particular day, the value may be "0" or the day may be omitted +> """ +> result = self._make_request( +> f"activities/{resource_path.value}/date/{start_date}/{end_date}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/base.py,cover b/fitbit_client/resources/base.py,cover new file mode 100644 index 0000000..21cc534 --- /dev/null +++ b/fitbit_client/resources/base.py,cover @@ -0,0 +1,498 @@ + # fitbit_client/resources/base.py + + # Standard library imports +> from datetime import datetime +> from inspect import currentframe +> from json import JSONDecodeError +> from json import dumps +> from logging import getLogger +> from typing import Any +> from typing import Dict +> from typing import Optional +> from typing import Set +> from typing import cast +> from urllib.parse import urlencode + + # Third party imports +> from requests import Response +> from requests_oauthlib import OAuth2Session + + # Local imports +> 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.types import JSONType + + # Constants for important fields to track in logging +> IMPORTANT_RESPONSE_FIELDS: Set[str] = { +> "access", +> "date", +> "dateTime", +> "deviceId", +> "endTime", +> "foodId", +> "id", +> "logId", +> "mealTypeId", +> "name", +> "startTime", +> "subscriptionId", +> "unitId", +> } + + +> class BaseResource: +> """Provides foundational functionality for all Fitbit API resource classes. + +> The BaseResource class implements core functionality that all specific resource +> classes (Activity, Sleep, User, etc.) inherit. It handles API communication, +> authentication, error handling, URL construction, logging, and debugging support. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/ + +> The Fitbit API has two types of endpoints: +> 1. Public endpoints: /{endpoint} +> Used for database-wide operations like food search +> 2. User endpoints: /user/{user_id}/{endpoint} +> Used for user-specific operations like logging activities and food. + +> This base class provides: +> - URL construction for both endpoint types +> - Request handling with comprehensive error management +> - Response parsing with type safety +> - Detailed logging of requests, responses, and errors +> - Debug capabilities for API troubleshooting +> - OAuth2 authentication management + +> Note: +> All resource-specific classes inherit from this class and use its _make_request +> method to communicate with the Fitbit API. The class handles different response +> formats (JSON, XML), empty responses, and various error conditions automatically. +> """ + +> API_BASE: str = "https://api.fitbit.com" + +> def __init__(self, oauth_session: OAuth2Session, locale: str, language: str) -> None: +- """Initialize a new resource instance with authentication and locale settings. + +> Args: +> oauth_session: Authenticated OAuth2 session for API requests +> locale: Locale for API responses (e.g., 'en_US') +> language: Language for API responses (e.g., 'en_US') + +> The locale and language settings affect how the Fitbit API formats responses, +> particularly for things like: +> - Date and time formats +> - Measurement units (imperial vs metric) +> - Number formats (decimal separator, thousands separator) +> - Currency symbols and formats + +> These settings are passed with each request in the Accept-Locale and +> Accept-Language headers. +> """ +> self.headers: Dict = {"Accept-Locale": locale, "Accept-Language": language} +> self.oauth: OAuth2Session = oauth_session + # Initialize loggers +> self.logger = getLogger(f"fitbit_client.{self.__class__.__name__}") +> self.data_logger = getLogger("fitbit_client.data") + +> def _build_url( +> self, +> endpoint: str, +> user_id: str = "-", +> requires_user_id: bool = True, +> api_version: str = "1", +> ) -> str: +> """Constructs a complete Fitbit API URL for the specified endpoint. + +> This method handles both public endpoints (database-wide operations) and +> user-specific endpoints (operations on user data) by constructing the +> appropriate URL pattern. + +> Args: +> endpoint: API endpoint path (e.g., 'foods/log') +> user_id: User ID, defaults to '-' for authenticated user +> requires_user_id: Whether the endpoint requires user_id in the path +> api_version: API version to use (default: "1") + +> Returns: +> str: Complete API URL for the requested endpoint + +> Example URLs: +> User endpoint: https://api.fitbit.com/1/user/-/foods/log.json +> Public endpoint: https://api.fitbit.com/1/foods/search.json + +> Note: +> By default, endpoints are assumed to be user-specific. Set requires_user_id=False +> for public endpoints that operate on Fitbit's global database rather than +> user-specific data. The user_id parameter is ignored when requires_user_id is False. + +> The special user_id value "-" indicates the currently authenticated user. +> """ +> endpoint = endpoint.strip("/") +> if requires_user_id: +> 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]: +> """ +> Extract important fields from response data for logging. + +> Args: +> data: Response data dictionary + +> Returns: +> Dictionary containing only the important fields and their values + +> This method recursively searches through the response data for fields +> defined in IMPORTANT_RESPONSE_FIELDS, preserving their path in the +> response structure using dot notation. +> """ +> extracted = {} + +> def extract_recursive(d: Dict[str, Any], prefix: str = "") -> None: +> for key, value in d.items(): +> full_key = f"{prefix}.{key}" if prefix else key + +> if key in IMPORTANT_RESPONSE_FIELDS: +> extracted[full_key] = value + +> if isinstance(value, dict): +> extract_recursive(value, full_key) +> elif isinstance(value, list): +> for i, item in enumerate(value): +> if isinstance(item, dict): +> extract_recursive(item, f"{full_key}[{i}]") + +> extract_recursive(data) +> return extracted + +> def _get_calling_method(self) -> str: +> """ +> Get the name of the method that called _make_request. + +> Returns: +> Name of the calling method + +> This method walks up the call stack to find the first method that isn't +> one of our internal request handling methods. +> """ +> frame = currentframe() +> while frame: + # Skip our internal methods when looking for the caller +> if frame.f_code.co_name not in ( +> "_make_request", +> "_get_calling_method", +> "_handle_error_response", +> ): +> return frame.f_code.co_name +> 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: +> """ +> Handle logging for both success and error responses. + +> Args: +> calling_method: Name of the method that made the request +> endpoint: API endpoint that was called +> response: Response object from the request +> content: Optional parsed response content + +> This method logs both successful and failed requests with appropriate +> detail levels. For errors, it includes error types and messages when +> available. +> """ +> if response.status_code >= 400: +> if isinstance(content, dict) and "errors" in content: +> error = content["errors"][0] +> msg = ( +> f"Request failed for {endpoint} " +> f"(method: {calling_method}, status: {response.status_code}): " +> f"[{error['errorType']}] " +> ) +> if "fieldName" in error: +> msg += f"{error['fieldName']}: {error['message']}" +> else: +> msg += f"{error['message']}" +> self.logger.error(msg) +> else: +> self.logger.error( +> f"Request failed for {endpoint} " +> f"(method: {calling_method}, status: {response.status_code})" +> ) +> else: +> self.logger.info( +> f"{calling_method} succeeded for {endpoint} (status {response.status_code})" +> ) + +> def _log_data(self, calling_method: str, content: Dict) -> None: +> """ +> Log important fields from the response content. + +> Args: +> calling_method: Name of the method that made the request +> content: Response content to log + +> This method extracts and logs important fields from successful responses, +> creating a structured log entry with timestamp and context. +> """ +> important_fields = self._extract_important_fields(content) +> if important_fields: +> data_entry = { +> "timestamp": datetime.now().isoformat(), +> "method": calling_method, +> "fields": important_fields, +> } +> self.data_logger.info(dumps(data_entry)) + +> def _handle_json_response( +> self, calling_method: str, endpoint: str, response: Response +> ) -> JSONType: +> """ +> Handle a JSON response, including parsing and logging. + +> Args: +> calling_method: Name of the method that made the request +> endpoint: API endpoint that was called +> response: Response object from the request + +> Returns: +> Parsed JSON response data + +> Raises: +> JSONDecodeError: If the response cannot be parsed as JSON +> """ +> try: +> content = response.json() +> except JSONDecodeError: +> self.logger.error(f"Invalid JSON response from {endpoint}") +> raise + +> self._log_response(calling_method, endpoint, response, content) +> if isinstance(content, dict): +> self._log_data(calling_method, content) +> return cast(JSONType, content) + +> def _handle_error_response(self, response: Response) -> None: +> """ +> Parse error response and raise appropriate exception. + +> Args: +> response: Error response from the API + +> Raises: +> Appropriate exception class based on error type or status code + +> This method attempts to parse the error response and raise the most +> specific exception possible based on either the API's error type or +> the HTTP status code. +> """ +> try: +> error_data = response.json() +> except (JSONDecodeError, ValueError): +> error_data = { +> "errors": [ +> { +> "errorType": "system", +> "message": response.text or f"HTTP {response.status_code}", +> } +> ] +> } + +> error = error_data.get("errors", [{}])[0] +> error_type = error.get("errorType", "system") +> message = error.get("message", "Unknown error") +> field_name = error.get("fieldName") + +> exception_class = ERROR_TYPE_EXCEPTIONS.get( +> error_type, STATUS_CODE_EXCEPTIONS.get(response.status_code, FitbitAPIException) +> ) + +> self.logger.error( +> f"{exception_class.__name__}: {message} " +> f"[Type: {error_type}, Status: {response.status_code}]" +> f"{f', Field: {field_name}' if field_name else ''}" +> ) + +> raise exception_class( +> message=message, +> status_code=response.status_code, +> error_type=error_type, +> raw_response=error_data, +> field_name=field_name, +> ) + +> 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] = {}, +> user_id: str = "-", +> requires_user_id: bool = True, +> http_method: str = "GET", +> api_version: str = "1", +> debug: bool = False, +> ) -> JSONType: +> """Makes a request to the Fitbit API with error handling and debugging support. + +> This core method handles all API communication for the library. It constructs URLs, +> sends requests with proper authentication, processes responses, handles errors, +> and provides debugging capabilities. + +> Args: +> endpoint: API endpoint path (e.g., 'activities/steps') +> data: Optional form data for POST requests +> json: Optional JSON data for POST requests +> params: Optional query parameters for GET requests +> headers: Optional dict of additional HTTP headers to add to the request +> user_id: User ID, defaults to '-' for authenticated user +> requires_user_id: Whether the endpoint requires user_id in the path +> http_method: HTTP method to use (GET, POST, DELETE) +> api_version: API version to use (default: "1") +> debug: If True, prints a curl command to stdout to help with debugging + +> Returns: +> JSONType: The API response in one of these formats: +> - Dict[str, Any]: 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 + +> Raises: +> fitbit_client.exceptions.FitbitAPIException: Base class for all Fitbit API exceptions +> fitbit_client.exceptions.AuthorizationException: When there are authorization errors +> fitbit_client.exceptions.ExpiredTokenException: When the OAuth token has expired +> fitbit_client.exceptions.InsufficientPermissionsException: When the app lacks required permissions +> fitbit_client.exceptions.NotFoundException: When the requested resource doesn't exist +> fitbit_client.exceptions.RateLimitExceededException: When rate limits are exceeded +> fitbit_client.exceptions.ValidationException: When request parameters are invalid +> fitbit_client.exceptions.SystemException: When there are server-side errors + +> Note: +> Debug Mode functionality: +> When debug=True, this method prints a curl command to stdout that can +> be used to replicate the request manually, which is useful for: +> - Testing API endpoints directly +> - Debugging authentication/scope issues +> - Verifying request structure +> - Troubleshooting permission problems + +> The method automatically handles different response formats and appropriate +> error types based on the API's response. +> """ +> calling_method = self._get_calling_method() +> url = self._build_url(endpoint, user_id, requires_user_id, api_version) + +> if debug: +> curl_command = self._build_curl_command(url, http_method, data, json, params) +> print(f"\n# Debug curl command for {calling_method}:") +> print(curl_command) +> print() +> return None + +> self.headers.update(headers) + +> try: +> response: Response = self.oauth.request( +> http_method, url, data=data, json=json, params=params, headers=self.headers +> ) + + # Handle error responses +> if response.status_code >= 400: +> self._handle_error_response(response) + +> content_type = response.headers.get("content-type", "").lower() + + # Handle empty responses +> if response.status_code == 204 or not content_type: +> self.logger.info( +> f"{calling_method} succeeded for {endpoint} (status {response.status_code})" +> ) +> return None + + # Handle JSON responses +> if "application/json" in content_type: +> return self._handle_json_response(calling_method, endpoint, response) + + # Handle XML/TCX responses +> elif "application/vnd.garmin.tcx+xml" in content_type or "text/xml" in content_type: +> self._log_response(calling_method, endpoint, response) +> return cast(str, response.text) + + # Handle unexpected content types +> self.logger.error(f"Unexpected content type {content_type} for {endpoint}") +> return None + +> except Exception as e: +> self.logger.error( +> f"{e.__class__.__name__} in {calling_method} for {endpoint}: {str(e)}" +> ) +> raise diff --git a/fitbit_client/resources/body.py,cover b/fitbit_client/resources/body.py,cover new file mode 100644 index 0000000..7ed346d --- /dev/null +++ b/fitbit_client/resources/body.py,cover @@ -0,0 +1,419 @@ + # fitbit_client/resources/body.py + + # Standard library imports +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import BodyGoalType +> from fitbit_client.utils.date_validation import validate_date_param +> from fitbit_client.utils.types import JSONDict + + +> class BodyResource(BaseResource): +> """Provides access to Fitbit Body API for managing body measurements and goals. + +> This resource handles endpoints for tracking and managing body metrics including +> weight, body fat percentage, and BMI. It supports creating and retrieving logs +> of measurements, setting goals, and accessing historical body data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/ + +> Required Scopes: +> - weight: Required for all endpoints in this resource + +> Note: +> All weight and body fat data is returned in the unit system specified by the +> Accept-Language header provided during client initialization (imperial for en_US, +> metric for most other locales). BMI values are calculated automatically from +> weight logs and user profile data. +> """ + +> def create_bodyfat_goal(self, fat: float, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Creates or updates a user's body fat percentage goal. + +> This endpoint allows setting a target body fat percentage goal that will be +> displayed in the Fitbit app and used to track progress over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/create-bodyfat-goal/ + +> Args: +> fat: Target body fat percentage in the format X.XX (e.g., 22.5 for 22.5%) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The created body fat percentage goal + +> Raises: +> fitbit_client.exceptions.ValidationException: If fat percentage is not in valid range +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> This endpoint requires the 'weight' OAuth scope. Body fat values should be specified +> as a percentage in decimal format (e.g., 22.5 for 22.5%). Typical healthy ranges vary +> by age, gender, and fitness level, but generally fall between 10-30%. +> """ +> result = self._make_request( +> "body/log/fat/goal.json", +> params={"fat": fat}, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def create_bodyfat_log( +> self, +> fat: float, +> date: str, +> time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a body fat log entry for tracking body composition over time. + +> This endpoint allows recording a body fat percentage measurement for a specific +> date and time, which will be displayed in the Fitbit app and used in trends. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/create-bodyfat-log/ + +> Args: +> fat: Body fat measurement in the format X.XX (e.g., 22.5 for 22.5%) +> date: Log date in YYYY-MM-DD format or 'today' +> time: Optional time of measurement in HH:mm:ss format. If not provided, +> will default to last second of the day (23:59:59). +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The created body fat percentage log entry + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If fat percentage is not in valid range +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The returned Body Fat Log IDs are unique to the user, but not globally unique. +> 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} +> if time: +> params["time"] = time +> result = self._make_request( +> "body/log/fat.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="start_date") +> def create_weight_goal( +> self, +> start_date: str, +> start_weight: float, +> weight: Optional[float] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates or updates a user's weight goal for tracking progress. + +> This endpoint sets a target weight goal with starting parameters, which will be +> used to track progress in the Fitbit app and determine recommended weekly changes. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/create-weight-goal/ + +> Args: +> start_date: Weight goal start date in YYYY-MM-DD format or 'today' +> start_weight: Starting weight before reaching goal in X.XX format +> weight: Optional target weight goal in X.XX format. Required if user +> doesn't have an existing weight goal. +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The created weight goal with goal type and recommended changes + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If start_date format is invalid +> fitbit_client.exceptions.ValidationException: If weight values are invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Weight values should be specified in the unit system that corresponds +> to the Accept-Language header provided during client initialization +> (pounds for en_US, kilograms for most other locales). + +> The goalType is automatically determined by comparing start_weight to weight: +> - If target < start: "LOSE" +> - If target > start: "GAIN" +> - If target = start: "MAINTAIN" +> """ +> params = {"startDate": start_date, "startWeight": start_weight} +> if weight is not None: +> params["weight"] = weight +> result = self._make_request( +> "body/log/weight/goal.json", +> params=params, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def create_weight_log( +> self, +> weight: float, +> date: str, +> time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a weight log entry for tracking body weight over time. + +> This endpoint allows recording a weight measurement for a specific +> date and time, which will be displayed in the Fitbit app and used to +> calculate BMI and track progress toward weight goals. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/create-weight-log/ + +> Args: +> weight: Weight measurement in X.XX format (in kg or lbs based on user settings) +> date: Log date in YYYY-MM-DD format or 'today' +> time: Optional time of measurement in HH:mm:ss format. If not provided, +> will default to last second of the day (23:59:59). +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: The created weight log entry with BMI calculation + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If weight value is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Weight values should be in the unit system that corresponds to the +> Accept-Language header provided during client initialization +> (pounds for en_US, kilograms for most other locales). + +> BMI (Body Mass Index) is automatically calculated using the provided weight +> and the height stored in the user's profile settings. If the user's height +> is not set, BMI will not be calculated. + +> 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} +> if time: +> params["time"] = time +> result = self._make_request( +> "body/log/weight.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def delete_bodyfat_log( +> self, bodyfat_log_id: str, user_id: str = "-", debug: bool = False +> ) -> None: +> """Deletes a body fat log entry permanently. + +> This endpoint permanently removes a body fat percentage measurement from the user's logs. +> Once deleted, the data cannot be recovered. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/delete-bodyfat-log/ + +> Args: +> bodyfat_log_id: ID of the body fat log entry to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.ValidationException: If bodyfat_log_id is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted +> fitbit_client.exceptions.NotFoundException: If log entry does not exist + +> Note: +> Body fat log IDs can be obtained from the get_bodyfat_log method. +> Deleting logs will affect historical averages and trends in the Fitbit app. +> This operation cannot be undone, so use it cautiously. +> """ +> result = self._make_request( +> f"body/log/fat/{bodyfat_log_id}.json", +> user_id=user_id, +> http_method="DELETE", +> debug=debug, +> ) +> return cast(None, result) + +> def delete_weight_log( +> self, weight_log_id: str, user_id: str = "-", debug: bool = False +> ) -> None: +> """Deletes a weight log entry permanently. + +> This endpoint permanently removes a weight measurement from the user's logs. +> Once deleted, the data cannot be recovered. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/delete-weight-log/ + +> Args: +> weight_log_id: ID of the weight log entry to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.ValidationException: If weight_log_id is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted +> fitbit_client.exceptions.NotFoundException: If log entry does not exist + +> Note: +> Weight log IDs can be obtained from the get_weight_logs method. +> Deleting logs will affect historical averages, BMI calculations, and +> trend data in the Fitbit app. + +> When the most recent weight log is deleted, the previous weight log +> becomes the current weight displayed in the Fitbit app. + +> This operation cannot be undone, so use it cautiously. +> """ +> result = self._make_request( +> f"body/log/weight/{weight_log_id}.json", +> user_id=user_id, +> http_method="DELETE", +> debug=debug, +> ) +> return cast(None, result) + +> def get_body_goals( +> self, goal_type: BodyGoalType, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves a user's body fat percentage or weight goals. + +> This endpoint returns the currently set goals for either body fat percentage +> or weight, including target values and, for weight goals, additional parameters +> like start weight and recommended weekly changes. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/get-body-goals/ + +> Args: +> goal_type: Type of goal to retrieve (BodyGoalType.FAT or BodyGoalType.WEIGHT) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Goal information for either weight or body fat percentage + +> Raises: +> fitbit_client.exceptions.ValidationException: If goal_type is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Weight values are returned in the unit system specified by +> the Accept-Language header provided during client initialization +> (pounds for en_US, kilograms for most other locales). + +> The weightThreshold represents the recommended weekly weight change +> (loss or gain) to achieve the goal in a healthy manner. This is +> calculated based on the difference between starting and target weight. + +> If no goal has been set for the requested type, an empty goal object +> will be returned. +> """ +> result = self._make_request( +> f"body/log/{goal_type.value}/goal.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def get_bodyfat_log(self, date: str, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a user's body fat percentage logs for a specific date. + +> This endpoint returns all body fat percentage measurements recorded on the +> specified date, including those logged manually, via the API, or synced from +> compatible scales. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/get-bodyfat-log/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Body fat percentage logs for the specified date + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The source field indicates how the data was recorded: +> - "API": From Web API or manual entry in the Fitbit app +> - "Aria"/"Aria2": From Fitbit Aria scale +> - "AriaAir": From Fitbit Aria Air scale +> - "Withings": From Withings scale connected to Fitbit + +> Body fat percentage is measured differently depending on the source: +> - Bioelectrical impedance for compatible scales +> - User-entered estimates for manual entries + +> Multiple logs may exist for the same date if measurements were taken +> at different times or from different sources. +> """ +> result = self._make_request(f"body/log/fat/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_param() +> def get_weight_logs(self, date: str, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a user's weight logs for a specific date. + +> This endpoint returns all weight measurements recorded on the specified date, +> including those logged manually, via the API, or synced from compatible scales. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body/get-weight-log/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Weight logs for the specified date with BMI calculations + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The source field indicates how the data was recorded: +> - "API": From Web API or manual entry in the Fitbit app +> - "Aria"/"Aria2": From Fitbit Aria scale +> - "AriaAir": From Fitbit Aria Air scale +> - "Withings": From Withings scale connected to Fitbit + +> Weight values are returned in the unit system specified by the +> Accept-Language header provided during client initialization +> (pounds for en_US, kilograms for most other locales). + +> BMI (Body Mass Index) is automatically calculated using the recorded weight +> and the height stored in the user's profile settings. + +> The "fat" field is only included when body fat percentage was measured +> along with weight (typically from compatible scales like Aria). + +> Multiple logs may exist for the same date if measurements were taken +> at different times or from different sources. +> """ +> result = self._make_request( +> f"body/log/weight/date/{date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/body_timeseries.py,cover b/fitbit_client/resources/body_timeseries.py,cover new file mode 100644 index 0000000..6d10616 --- /dev/null +++ b/fitbit_client/resources/body_timeseries.py,cover @@ -0,0 +1,384 @@ + # fitbit_client/resources/body_timeseries.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import ValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import BodyResourceType +> from fitbit_client.resources.constants import BodyTimePeriod +> from fitbit_client.resources.constants import MaxRanges +> 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 BodyTimeSeriesResource(BaseResource): +> """Provides access to Fitbit Body Time Series API for retrieving body measurements over time. + +> This resource handles endpoints for retrieving historical body measurement data +> including weight, body fat percentage, and BMI over specified time periods. +> It enables tracking and analysis of body composition changes over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/ + +> Required Scopes: +> - weight: Required for all endpoints in this resource + +> Note: +> The Body Time Series API provides access to historical body measurement data, +> which is useful for tracking trends and progress over time. Each measurement +> type (weight, body fat, BMI) has specific date range limitations: + +> - BMI data: Available for up to 1095 days (3 years) +> - Body fat data: Available for up to 30 days +> - Weight data: Available for up to 31 days + +> Measurements are returned in the user's preferred unit system (metric or imperial), +> which can be determined by the Accept-Language header provided during API calls. + +> Data is recorded when users log body measurements manually or when they use +> connected scales that automatically sync with their Fitbit account. +> """ + +> @validate_date_param() +> def get_body_timeseries_by_date( +> self, +> resource_type: BodyResourceType, +> date: str, +> period: BodyTimePeriod, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves body metrics for a given resource over a period ending on the specified date. + +> This endpoint returns time series data for the specified body measurement type +> (BMI, body fat percentage, or weight) over a time period ending on the given date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-body-timeseries-by-date/ + +> Args: +> resource_type: Type of body measurement (BodyResourceType.BMI, BodyResourceType.FAT, +> or BodyResourceType.WEIGHT) +> date: The end date in YYYY-MM-DD format or 'today' +> period: The time period for data retrieval (e.g., BodyTimePeriod.ONE_DAY, +> BodyTimePeriod.SEVEN_DAYS, etc.) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Time series data for the specified body measurement type and time period + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If period is not supported for fat/weight +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> For fat and weight resources, only periods up to BodyTimePeriod.ONE_MONTH are supported. +> The periods BodyTimePeriod.THREE_MONTHS, BodyTimePeriod.SIX_MONTHS, +> BodyTimePeriod.ONE_YEAR, and BodyTimePeriod.MAX are not available for these resources. + +> The JSON field name in the response varies based on resource_type: +> - BodyResourceType.BMI: "body-bmi" +> - BodyResourceType.FAT: "body-fat" +> - BodyResourceType.WEIGHT: "body-weight" + +> Values are returned as strings representing: +> - Weight: kilograms or pounds based on user settings +> - Body fat: percentage (e.g., "22.5" means 22.5%) +> - BMI: standard BMI value (e.g., "21.3") + +> The endpoint returns all available data points within the requested period, +> which may include multiple measurements per day if the user logged them. +> Days without measurements will not appear in the results. +> """ + # Validate period restrictions for fat and weight +> if resource_type in (BodyResourceType.FAT, BodyResourceType.WEIGHT): +> if period in ( +> BodyTimePeriod.THREE_MONTHS, +> BodyTimePeriod.SIX_MONTHS, +> BodyTimePeriod.ONE_YEAR, +> BodyTimePeriod.MAX, +> ): +> raise ValidationException( +> message=f"Period {period.value} not supported for {resource_type.value}", +> status_code=400, +> error_type="validation", +> field_name="period", +> ) + +> result = self._make_request( +> f"body/{resource_type.value}/date/{date}/{period.value}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(start_field="begin_date") +> def get_body_timeseries_by_date_range( +> self, +> resource_type: BodyResourceType, +> begin_date: str, +> end_date: str, +> user_id: str = "-", +> debug: bool = False, +> ) -> ( +> JSONDict +> ): # Note: This is the one place in the whole API where it's called "begin_date" not "start_date" ¯\_(ツ)_/¯ +> """Retrieves body metrics for a given resource over a specified date range. + +> This endpoint returns time series data for the specified body measurement type +> (BMI, body fat percentage, or weight) between two specified dates. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-body-timeseries-by-date-range/ + +> Args: +> resource_type: Type of body measurement (BodyResourceType.BMI, BodyResourceType.FAT, +> or BodyResourceType.WEIGHT) +> begin_date: The start date in YYYY-MM-DD format or 'today' +> end_date: The end date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Time series data for the specified body measurement type and date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range exceeds maximum allowed days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Maximum date ranges vary by resource type: +> - BMI: 1095 days (3 years) +> - FAT: 30 days +> - WEIGHT: 31 days + +> The JSON field name in the response varies based on resource_type: +> - BodyResourceType.BMI: "body-bmi" +> - BodyResourceType.FAT: "body-fat" +> - BodyResourceType.WEIGHT: "body-weight" + +> Values are returned as strings representing: +> - Weight: kilograms or pounds based on user settings +> - Body fat: percentage (e.g., "22.5" means 22.5%) +> - BMI: standard BMI value (e.g., "21.3") + +> The endpoint returns all available data points within the requested date range, +> which may include multiple measurements per day if the user logged them. +> Days without measurements will not appear in the results. + +> Uniquely in the Fitbit API, this endpoint uses "begin_date" rather than +> "start_date" in its URL path (unlike most other Fitbit API endpoints). +> """ +> max_days = { +> BodyResourceType.BMI: MaxRanges.GENERAL, +> BodyResourceType.FAT: MaxRanges.BODY_FAT, +> BodyResourceType.WEIGHT: MaxRanges.WEIGHT, +> }[resource_type] + + # Since we have different max days for different resources, we need to validate here +> validate_date_range(begin_date, end_date, max_days, resource_type.value) + +> result = self._make_request( +> f"body/{resource_type.value}/date/{begin_date}/{end_date}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def get_bodyfat_timeseries_by_date( +> self, date: str, period: BodyTimePeriod, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns body fat percentage data for a specified time period. + +> This endpoint retrieves time series data for body fat percentage measurements +> over a period ending on the specified date. This provides a convenient way +> to track changes in body composition over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-bodyfat-timeseries-by-date/ + +> Args: +> date: The end date in YYYY-MM-DD format or 'today' +> period: The range for which data will be returned (only up to 1m supported) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Body fat percentage time series data for the specified time period + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If period is not supported + +> Note: +> Only periods up to BodyTimePeriod.ONE_MONTH are supported. The periods +> BodyTimePeriod.THREE_MONTHS, BodyTimePeriod.SIX_MONTHS, BodyTimePeriod.ONE_YEAR, +> and BodyTimePeriod.MAX are not available for body fat data. + +> The endpoint will return all available data points within the requested period, +> which may include multiple measurements per day if the user logged them. +> """ +> if period in ( +> BodyTimePeriod.THREE_MONTHS, +> BodyTimePeriod.SIX_MONTHS, +> BodyTimePeriod.ONE_YEAR, +> BodyTimePeriod.MAX, +> ): +> raise ValidationException( +> message=f"Period {period.value} not supported for body fat", +> status_code=400, +> error_type="validation", +> field_name="period", +> ) + +> result = self._make_request( +> f"body/log/fat/date/{date}/{period.value}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=MaxRanges.BODY_FAT, resource_name="body fat") +> def get_bodyfat_timeseries_by_date_range( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves body fat percentage measurements over a specified date range. + +> This endpoint returns all body fat percentage logs between the specified start and end dates. +> Body fat percentage is a key metric for tracking body composition changes over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-bodyfat-timeseries-by-date-range/ + +> Args: +> start_date: The start date in YYYY-MM-DD format or 'today' +> end_date: The end date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Body fat percentage time series data for the specified date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range exceeds 30 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Maximum range is 30 days for body fat percentage data. Requests for longer +> periods will result in an InvalidDateRangeException. + +> Body fat percentage values are returned as strings representing percentages +> (e.g., "22.5" means 22.5% body fat). + +> The endpoint returns all available data points within the requested range, +> which may include multiple measurements per day if the user logged them. +> Days without measurements will not appear in the results. +> """ +> result = self._make_request( +> f"body/log/fat/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param() +> def get_weight_timeseries_by_date( +> self, date: str, period: BodyTimePeriod, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves weight measurements over a period ending on the specified date. + +> This endpoint returns weight logs over a time period ending on the specified date. +> Weight data is presented in the user's preferred unit system (kilograms or pounds). + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-weight-timeseries-by-date/ + +> Args: +> date: The end date in YYYY-MM-DD format or 'today' +> period: The range for which data will be returned (only up to ONE_MONTH supported) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Weight time series data for the specified time period + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If period is not supported +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Only periods up to BodyTimePeriod.ONE_MONTH are supported. The periods +> BodyTimePeriod.THREE_MONTHS, BodyTimePeriod.SIX_MONTHS, BodyTimePeriod.ONE_YEAR, +> and BodyTimePeriod.MAX are not available for weight data. + +> Weight values are returned as strings representing either: +> - Kilograms for users with metric settings +> - Pounds for users with imperial settings + +> The unit system is determined by the user's account settings and +> can also be influenced by the Accept-Language header provided +> during API calls. +> """ +> if period in ( +> BodyTimePeriod.THREE_MONTHS, +> BodyTimePeriod.SIX_MONTHS, +> BodyTimePeriod.ONE_YEAR, +> BodyTimePeriod.MAX, +> ): +> raise ValidationException( +> message=f"Period {period.value} not supported for weight", +> status_code=400, +> error_type="validation", +> field_name="period", +> ) + +> result = self._make_request( +> f"body/log/weight/date/{date}/{period.value}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=MaxRanges.WEIGHT, resource_name="weight") +> def get_weight_timeseries_by_date_range( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves weight measurements over a specified date range. + +> This endpoint returns all weight logs between the specified start and end dates. +> Weight data is presented in the user's preferred unit system (kilograms or pounds). + +> API Reference: https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-weight-timeseries-by-date-range/ + +> Args: +> start_date: The start date in YYYY-MM-DD format or 'today' +> end_date: The end date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Weight time series data for the specified date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range exceeds 31 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Maximum range is 31 days for weight data. Requests for longer periods +> will result in an InvalidDateRangeException. + +> Weight values are returned as strings representing either: +> - Kilograms for users with metric settings +> - Pounds for users with imperial settings + +> The endpoint returns all available data points within the requested range, +> which may include multiple measurements per day if the user logged them. +> Days without measurements will not appear in the results. + +> To retrieve weight data for longer historical periods, you can make multiple +> requests with different date ranges and combine the results. +> """ +> result = self._make_request( +> f"body/log/weight/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/breathing_rate.py,cover b/fitbit_client/resources/breathing_rate.py,cover new file mode 100644 index 0000000..f8e5e75 --- /dev/null +++ b/fitbit_client/resources/breathing_rate.py,cover @@ -0,0 +1,109 @@ + # fitbit_client/resources/breathing_rate.py + + # Standard library imports +> from typing import Dict +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> 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 + + +> class BreathingRateResource(BaseResource): +> """Provides access to Fitbit Breathing Rate API for retrieving respiratory measurements. + +> This resource handles endpoints for retrieving breathing rate (respiratory rate) data, +> which measures the average breaths per minute during sleep. The API provides both daily +> summaries and interval-based historical data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/breathing-rate/ + +> Required Scopes: respiratory_rate + +> Note: +> Data is collected during the user's "main sleep" period (longest sleep period) and +> requires specific conditions: +> - Sleep periods must be at least 3 hours long +> - User must be relatively still during measurement +> - Data becomes available approximately 15 minutes after device sync +> - Sleep periods may span across midnight, so data might reflect previous day's sleep +> - Not all Fitbit devices support breathing rate measurement +> """ + +> @validate_date_param() +> def get_breathing_rate_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns breathing rate data summary for a single date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/breathing-rate/get-br-summary-by-date/ + +> Args: +> date: Date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Breathing rate data containing average breaths per minute during sleep + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> Data is collected during the user's main sleep period (longest sleep period). +> The measurement may reflect sleep that began the previous day. +> For example, requesting data for 2023-01-01 may include measurements +> from sleep that started on 2022-12-31. + +> Not all fields may be present in the response, depending on the +> Fitbit device model and quality of sleep data captured. + +> Breathing rate data requires: +> - Compatible Fitbit device that supports this measurement +> - Sleep period at least 3 hours long +> - Relatively still sleep (excessive movement reduces accuracy) +> """ +> result = self._make_request(f"br/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=30) +> def get_breathing_rate_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns breathing rate data for a specified date range. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/breathing-rate/get-br-summary-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Breathing rate data for each day in the specified date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range exceeds 30 days +> or start_date is after end_date + +> Note: +> Maximum date range is 30 days. +> Data for each day is collected during that day's main sleep period (longest sleep). +> Measurements may reflect sleep that began on the previous day. + +> Days without valid breathing rate data (insufficient sleep, device not worn, etc.) +> will not appear in the results array. + +> Breathing rate values are typically in the range of 12-20 breaths per minute during +> sleep, with deep sleep typically showing slightly lower rates than REM sleep. +> The 'lowBreathingRateDisturbances' field counts instances where the breathing rate +> dropped significantly below the user's normal range during sleep. +> """ +> result = self._make_request( +> f"br/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/cardio_fitness_score.py,cover b/fitbit_client/resources/cardio_fitness_score.py,cover new file mode 100644 index 0000000..ae0029a --- /dev/null +++ b/fitbit_client/resources/cardio_fitness_score.py,cover @@ -0,0 +1,106 @@ + # fitbit_client/resources/cardio_fitness_score.py + + # Standard library imports +> from typing import Dict +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> 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 + + +> class CardioFitnessScoreResource(BaseResource): +> """Provides access to Fitbit Cardio Fitness Score (VO2 Max) API for cardiovascular fitness data. + +> This resource handles endpoints for retrieving cardio fitness scores (VO2 Max), which +> represent the maximum rate at which the heart, lungs, and muscles can effectively use +> oxygen during exercise. Higher values generally indicate better cardiovascular fitness. + +> Fitbit estimates VO2 Max based on resting heart rate, exercise heart rate response, +> age, gender, weight, and (when available) GPS-tracked runs. The API provides access +> to both single-day and multi-day data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/cardio-fitness-score/ + +> Required Scopes: +> - cardio_fitness (for all cardio fitness endpoints) + +> Note: +> - Values are always returned in ml/kg/min regardless of the user's unit preferences +> - Values may be returned either as a range (if no run data is available) +> or as a single numeric value (if the user uses GPS for runs) +> - For most users, cardio fitness scores typically range from 30-60 ml/kg/min +> - Not all Fitbit devices support cardio fitness score measurements +> - Scores may change throughout the day based on activity levels, heart rate, +> weight changes, and other factors +> - Data becomes available approximately 15 minutes after device sync +> """ + +> @validate_date_param() +> def get_vo2_max_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns cardio fitness (VO2 Max) data for a single date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/cardio-fitness-score/get-vo2max-summary-by-date/ + +> Args: +> date: Date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: VO2 max data for the specified date (either a precise value or a range) + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Values are always reported in ml/kg/min units +> - If the user has GPS run data, a single "vo2Max" value is returned +> - If no GPS run data is available, a "vo2MaxRange" with "low" and "high" values is returned +> - A missing date in the response means no cardio score was calculated that day +> - Scores may change throughout the day based on activity levels and heart rate +> - Higher values (typically 40-60 ml/kg/min) indicate better cardiovascular fitness +> """ +> result = self._make_request(f"cardioscore/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=30) +> def get_vo2_max_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns cardio fitness (VO2 Max) data for a specified date range. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/cardio-fitness-score/get-vo2max-summary-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or "today" +> end_date: End date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: VO2 max data for each date in the specified range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range exceeds 30 days +> or start_date is after end_date + +> Note: +> - Maximum date range is 30 days +> - Values are always reported in ml/kg/min units +> - Response may include a mix of exact values and ranges, depending on available data +> - Days without a cardio fitness score will not appear in the results +> - Each day's entry may contain either: +> * "vo2Max": A precise measurement (if GPS run data is available) +> * "vo2MaxRange": A range with "low" and "high" values (if no GPS data) +> - The data is particularly useful for tracking cardiovascular fitness changes over time +> """ +> result = self._make_request( +> f"cardioscore/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/constants.py,cover b/fitbit_client/resources/constants.py,cover new file mode 100644 index 0000000..885ca69 --- /dev/null +++ b/fitbit_client/resources/constants.py,cover @@ -0,0 +1,281 @@ + # fitbit_client/resources/constants.py + + # Standard library imports +> from enum import Enum + + +> class Period(str, Enum): +> """Standard time periods for requesting data from Fitbit API endpoints. + +> These period values are used across multiple API endpoints to specify the +> timeframe for retrieving data. Each value represents a specific duration +> ending on the date specified in the request. + +> Note: +> Different resources support different subsets of these periods. +> Always check individual resource documentation for supported values. +> Some resources (like body fat and weight) only support shorter periods, +> while others (like activity and sleep) may support longer periods. +> """ + +> ONE_DAY = "1d" # One day +> SEVEN_DAYS = "7d" # Seven days +> THIRTY_DAYS = "30d" # Thirty days +> ONE_WEEK = "1w" # One week +> ONE_MONTH = "1m" # One month +> THREE_MONTHS = "3m" # Three months +> SIX_MONTHS = "6m" # Six months +> ONE_YEAR = "1y" # One year +> MAX = "max" # Maximum available data + + +> class ActivityGoalType(str, Enum): +> """Activity goal types supported by Fitbit""" + +> ACTIVE_MINUTES = "activeMinutes" +> ACTIVE_ZONE_MINUTES = "activeZoneMinutes" +> CALORIES_OUT = "caloriesOut" +> DISTANCE = "distance" +> FLOORS = "floors" +> STEPS = "steps" + + +> class MaxRanges(int, Enum): +> """Maximum date ranges (in days) allowed for various Fitbit API resources. + +> These values define the maximum number of days that can be requested in a single +> API call when using date range endpoints. Exceeding these limits will result in +> a ValidationException from the API. + +> Note: +> When requesting data over periods longer than these limits, you must make +> multiple API calls with smaller date ranges and combine the results. +> """ + +> BREATHING_RATE = 30 +> BODY_FAT = 30 +> WEIGHT = 31 +> ACTIVITY = 31 +> SLEEP = 100 +> GENERAL = 1095 +> INTRADAY = 1 + + +> class ActivityTimeSeriesPath(str, Enum): +> """Resource paths available for activity time series data + +> API Reference: https://dev.fitbit.com/build/reference/web-api/activity-timeseries/get-activity-timeseries-by-date/#Resource-Options +> """ + +> ACTIVITY_CALORIES = "activityCalories" +> CALORIES = "calories" +> CALORIES_BMR = "caloriesBMR" +> DISTANCE = "distance" +> ELEVATION = "elevation" +> FLOORS = "floors" +> MINUTES_SEDENTARY = "minutesSedentary" +> MINUTES_LIGHTLY_ACTIVE = "minutesLightlyActive" +> MINUTES_FAIRLY_ACTIVE = "minutesFairlyActive" +> MINUTES_VERY_ACTIVE = "minutesVeryActive" +> STEPS = "steps" +> SWIMMING_STROKES = "swimming-strokes" + + # Tracker-only paths +> TRACKER_ACTIVITY_CALORIES = "tracker/activityCalories" +> TRACKER_CALORIES = "tracker/calories" +> TRACKER_DISTANCE = "tracker/distance" +> TRACKER_ELEVATION = "tracker/elevation" +> TRACKER_FLOORS = "tracker/floors" +> TRACKER_MINUTES_SEDENTARY = "tracker/minutesSedentary" +> TRACKER_MINUTES_LIGHTLY_ACTIVE = "tracker/minutesLightlyActive" +> TRACKER_MINUTES_FAIRLY_ACTIVE = "tracker/minutesFairlyActive" +> TRACKER_MINUTES_VERY_ACTIVE = "tracker/minutesVeryActive" +> TRACKER_STEPS = "tracker/steps" + + +> class ActivityGoalPeriod(str, Enum): +> """Periods for the user's specified current activity goals.""" + +> WEEKLY = "weekly" +> DAILY = "daily" + + +> class GoalType(str, Enum): +> """Goal types for body weight goals.""" + +> LOSE = "LOSE" +> GAIN = "GAIN" +> MAINTAIN = "MAINTAIN" + + +> class BodyGoalType(str, Enum): +> """Types of body measurement goals supported by the Get Body Goals endpoint.""" + +> FAT = "fat" +> WEIGHT = "weight" + + +> class BodyTimePeriod(str, Enum): +> """Time periods for body measurement time series endpoints.""" + +> ONE_DAY = "1d" +> SEVEN_DAYS = "7d" +> THIRTY_DAYS = "30d" +> ONE_WEEK = "1w" +> ONE_MONTH = "1m" +> THREE_MONTHS = "3m" +> SIX_MONTHS = "6m" +> ONE_YEAR = "1y" +> MAX = "max" + + +> class BodyResourceType(str, Enum): +> """Resource types for body measurement time series.""" + +> BMI = "bmi" +> FAT = "fat" +> WEIGHT = "weight" + + +> class WeekDay(str, Enum): +> """Days of the week for alarm settings.""" + +> MONDAY = "MONDAY" +> TUESDAY = "TUESDAY" +> WEDNESDAY = "WEDNESDAY" +> THURSDAY = "THURSDAY" +> FRIDAY = "FRIDAY" +> SATURDAY = "SATURDAY" +> SUNDAY = "SUNDAY" + + +> class MealType(int, Enum): +> """Meal types supported by the Fitbit nutrition API.""" + +> BREAKFAST = 1 +> MORNING_SNACK = 2 +> LUNCH = 3 +> AFTERNOON_SNACK = 4 +> DINNER = 5 +> EVENING_SNACK = 6 # this works even though it is not documented +> ANYTIME = 7 + + +> class FoodFormType(str, Enum): +> """Food texture types for creating custom foods.""" + +> LIQUID = "LIQUID" +> DRY = "DRY" + + +> class FoodPlanIntensity(str, Enum): +> """Intensity levels for food plan goals.""" + +> MAINTENANCE = "MAINTENANCE" +> EASIER = "EASIER" +> MEDIUM = "MEDIUM" +> KINDAHARD = "KINDAHARD" +> HARDER = "HARDER" + + +> class WaterUnit(str, Enum): +> """Valid units for water measurement.""" + +> MILLILITERS = "ml" +> FLUID_OUNCES = "fl oz" +> CUPS = "cup" + + +> class NutritionalValue(str, Enum): +> """Common nutritional value parameter names for food creation and logging.""" + + # Common Nutrients +> CALORIES_FROM_FAT = "caloriesFromFat" +> TOTAL_FAT = "totalFat" +> TRANS_FAT = "transFat" +> SATURATED_FAT = "saturatedFat" +> CHOLESTEROL = "cholesterol" +> SODIUM = "sodium" +> POTASSIUM = "potassium" +> TOTAL_CARBOHYDRATE = "totalCarbohydrate" +> DIETARY_FIBER = "dietaryFiber" +> SUGARS = "sugars" +> PROTEIN = "protein" + + # Vitamins +> VITAMIN_A = "vitaminA" # IU +> VITAMIN_B6 = "vitaminB6" +> VITAMIN_B12 = "vitaminB12" +> VITAMIN_C = "vitaminC" # mg +> VITAMIN_D = "vitaminD" # IU +> VITAMIN_E = "vitaminE" # IU +> BIOTIN = "biotin" # mg +> FOLIC_ACID = "folicAcid" # mg +> NIACIN = "niacin" # mg +> PANTOTHENIC_ACID = "pantothenicAcid" # mg +> RIBOFLAVIN = "riboflavin" # mg +> THIAMIN = "thiamin" # mg + + # Dietary Minerals +> CALCIUM = "calcium" # g +> COPPER = "copper" # g +> IRON = "iron" # mg +> MAGNESIUM = "magnesium" # mg +> PHOSPHORUS = "phosphorus" # g +> IODINE = "iodine" # mcg +> ZINC = "zinc" # mg + + +> class NutritionResource(str, Enum): +> """Resources available for nutrition time series data.""" + +> CALORIES_IN = "caloriesIn" +> WATER = "water" + + +> class SubscriptionCategory(str, Enum): +> """Categories of data available for Fitbit API subscriptions""" + +> ACTIVITIES = "activities" # Requires activity scope +> BODY = "body" # Requires weight scope +> FOODS = "foods" # Requires nutrition scope +> SLEEP = "sleep" # Requires sleep scope +> USER_REVOKED_ACCESS = "userRevokedAccess" # No scope required + + +> class Gender(str, Enum): +> """Gender options for user profile""" + +> MALE = "MALE" +> FEMALE = "FEMALE" +> NA = "NA" + + +> class ClockTimeFormat(str, Enum): +> """Time display format options""" + +> TWELVE_HOUR = "12hour" +> TWENTY_FOUR_HOUR = "24hour" + + +> class StartDayOfWeek(str, Enum): +> """Options for first day of week""" + +> SUNDAY = "SUNDAY" +> MONDAY = "MONDAY" + + +> class IntradayDetailLevel(str, Enum): +> """Detail levels for intraday data""" + +> ONE_SECOND = "1sec" +> ONE_MINUTE = "1min" +> FIVE_MINUTES = "5min" +> FIFTEEN_MINUTES = "15min" + + +> class SortDirection(str, Enum): +> """Sort directions for lists""" + +> ASCENDING = "asc" +> DESCENDING = "desc" diff --git a/fitbit_client/resources/device.py,cover b/fitbit_client/resources/device.py,cover new file mode 100644 index 0000000..615dc6b --- /dev/null +++ b/fitbit_client/resources/device.py,cover @@ -0,0 +1,205 @@ + # fitbit_client/resources/device.py + + # Standard library imports +> from typing import List +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import WeekDay +> from fitbit_client.utils.types import JSONDict +> from fitbit_client.utils.types import JSONList + + +> class DeviceResource(BaseResource): +> """Provides access to Fitbit Device API for managing paired devices and alarms. + +> This resource handles endpoints for retrieving information about devices paired to +> a user's account, as well as creating, updating, and deleting device alarms. +> The API provides device details such as battery level, version, features, sync status, +> and more. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/ + +> Required Scopes: settings + +> Note: +> Alarm endpoints (create, update, delete) are only supported for older Fitbit devices +> that configure alarms via the mobile application. Newer devices with on-device alarm +> applications do not support these endpoints. Currently, only the get_devices method +> is fully implemented. +> """ + +> def create_alarm( +> self, +> tracker_id: str, +> time: str, +> enabled: bool, +> recurring: bool, +> week_days: List[WeekDay], +> user_id: str = "-", +> ) -> JSONDict: +> """NOT IMPLEMENTED. Creates an alarm on a paired Fitbit device. + +> This endpoint would allow creation of device alarms with various settings including +> time, recurrence schedule, and enabled status. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/add-alarm/ + +> Args: +> tracker_id: The ID of the tracker to add the alarm to (from get_devices) +> time: Alarm time in HH:MM+XX:XX format (e.g. "07:00+00:00") +> enabled: Whether the alarm is enabled (True) or disabled (False) +> recurring: Whether the alarm repeats (True) or is a single event (False) +> week_days: List of WeekDay enum values indicating which days the alarm repeats +> user_id: Optional user ID, defaults to current user ("-") + +> Returns: +> JSONDict: Details of the created alarm + +> Raises: +> NotImplementedError: This method is not yet implemented + +> Note: +> This endpoint only works with older Fitbit devices that configure alarms via +> the API. Newer devices with on-device alarm applications do not support this +> endpoint. This method is provided for API completeness but is not currently +> implemented in this client. +> """ +> raise NotImplementedError + +> def delete_alarm(self, tracker_id: str, alarm_id: str, user_id: str = "-") -> None: +> """NOT IMPLEMENTED. Deletes an alarm from a paired Fitbit device. + +> This endpoint would allow deletion of existing alarms from Fitbit devices +> by specifying the tracker ID and alarm ID. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/delete-alarm/ + +> Args: +> tracker_id: The ID of the tracker containing the alarm (from get_devices) +> alarm_id: The ID of the alarm to delete (from get_alarms) +> user_id: Optional user ID, defaults to current user ("-") + +> Returns: +> None + +> Raises: +> NotImplementedError: This method is not yet implemented + +> Note: +> This endpoint only works with older Fitbit devices that configure alarms via +> the API. Newer devices with on-device alarm applications do not support this +> endpoint. This method is provided for API completeness but is not currently +> implemented in this client. +> """ +> raise NotImplementedError + +> def get_alarms(self, tracker_id: str, user_id: str = "-") -> JSONDict: +> """NOT IMPLEMENTED. Retrieves a list of alarms for a paired Fitbit device. + +> This endpoint would return all configured alarms for a specific Fitbit device, +> including their time settings, enabled status, and recurrence patterns. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/get-alarms/ + +> Args: +> tracker_id: The ID of the tracker to get alarms for (from get_devices) +> user_id: Optional user ID, defaults to current user ("-") + +> Returns: +> JSONDict: Dictionary containing a list of alarms + +> Raises: +> NotImplementedError: This method is not yet implemented + +> Note: +> This endpoint only works with older Fitbit devices that configure alarms via +> the API. Newer devices with on-device alarm applications do not support this +> endpoint. This method is provided for API completeness but is not currently +> implemented in this client. +> """ +> raise NotImplementedError + +> def get_devices(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Returns a list of Fitbit devices paired to a user's account. + +> This endpoint provides information about all devices connected to a user's Fitbit +> account, including trackers, watches, and scales. The data includes battery status, +> device model, features, sync status, and other device-specific information. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/get-devices/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of Fitbit devices paired to the user's account with details like battery level, model, and features + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The exact fields returned depend on the device type. Newer devices provide +> more detailed information than older models. Some devices may return additional +> fields not listed here, such as firmware details, hardware versions, or device +> capabilities. + +> The 'features' array lists device capabilities like heart rate tracking, +> GPS, SpO2 monitoring, etc. These can be used to determine what types of +> data are available for a particular device. +> """ +> return cast(JSONList, self._make_request("devices.json", user_id=user_id, debug=debug)) + +> def update_alarm( +> self, +> tracker_id: str, +> alarm_id: str, +> time: str, +> enabled: bool, +> recurring: bool, +> week_days: List[WeekDay], +> snooze_length: int, +> snooze_count: int, +> label: Optional[str] = None, +> vibe: Optional[str] = None, +> user_id: str = "-", +> ) -> JSONDict: +> """NOT IMPLEMENTED. Updates an existing alarm on a paired Fitbit device. + +> This endpoint would allow modification of alarm settings including time, +> recurrence pattern, snooze settings, and labels. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/devices/update-alarm/ + +> Args: +> tracker_id: The ID of the tracker containing the alarm (from get_devices) +> alarm_id: The ID of the alarm to update (from get_alarms) +> time: Alarm time in HH:MM+XX:XX format (e.g. "07:00+00:00") +> enabled: Whether the alarm is enabled (True) or disabled (False) +> recurring: Whether the alarm repeats (True) or is a single event (False) +> week_days: List of WeekDay enum values indicating which days the alarm repeats +> snooze_length: Length of snooze in minutes +> snooze_count: Number of times the alarm can be snoozed +> label: Optional label for the alarm +> vibe: Optional vibration pattern +> user_id: Optional user ID, defaults to current user ("-") + +> Returns: +> JSONDict: Details of the updated alarm + +> Raises: +> NotImplementedError: This method is not yet implemented + +> Note: +> This endpoint only works with older Fitbit devices that configure alarms via +> the API. Newer devices with on-device alarm applications do not support this +> endpoint. This method is provided for API completeness but is not currently +> implemented in this client. + +> The available vibration patterns (vibe parameter) and supported snooze settings +> vary by device model. +> """ +> raise NotImplementedError diff --git a/fitbit_client/resources/electrocardiogram.py,cover b/fitbit_client/resources/electrocardiogram.py,cover new file mode 100644 index 0000000..6fca78d --- /dev/null +++ b/fitbit_client/resources/electrocardiogram.py,cover @@ -0,0 +1,103 @@ + # fitbit_client/resources/electrocardiogram.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import SortDirection +> 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 + + +> class ElectrocardiogramResource(BaseResource): +> """Provides access to Fitbit Electrocardiogram (ECG) API for retrieving heart rhythm assessments. + +> This resource handles endpoints for retrieving ECG readings taken using compatible Fitbit devices. +> ECG (electrocardiogram) readings can help detect signs of atrial fibrillation (AFib), +> which is an irregular heart rhythm that requires medical attention. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/electrocardiogram/ + +> Required Scopes: +> - electrocardiogram (for all ECG endpoints) + +> Note: +> The ECG API is for research use or investigational use only, and is not +> intended for clinical or diagnostic purposes. ECG results do not replace +> traditional diagnosis methods and should not be interpreted as medical advice. + +> ECG readings require a compatible Fitbit device (e.g., Fitbit Sense or newer) +> and proper finger placement during measurement. The measurement process takes +> approximately 30 seconds. + +> ECG results are classified into several categories: +> - Normal sinus rhythm: No signs of atrial fibrillation detected +> - Atrial fibrillation: Irregular rhythm that may indicate AFib +> - Inconclusive: Result could not be classified +> - Inconclusive (High heart rate): Heart rate was too high for assessment +> - Inconclusive (Low heart rate): Heart rate was too low for assessment +> - Inconclusive (Poor reading): Signal quality was insufficient for assessment +> """ + +> @validate_date_param(field_name="before_date") +> @validate_date_param(field_name="after_date") +> @validate_pagination_params(max_limit=10) +> def get_ecg_log_list( +> self, +> before_date: Optional[str] = None, +> after_date: Optional[str] = None, +> sort: SortDirection = SortDirection.DESCENDING, +> limit: int = 10, +> offset: int = 0, +> user_id: str = "-", +> debug: bool = False, +> ) -> Dict[str, Any]: +> """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/ + +> Args: +> before_date: Return entries before this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> after_date: Return entries after this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> sort: Sort order - must use SortDirection.ASCENDING with after_date and +> SortDirection.DESCENDING with before_date (default: DESCENDING) +> limit: Number of entries to return (max 10, default: 10) +> offset: Pagination offset (only 0 is supported by the Fitbit API) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: ECG readings with classifications and pagination information + +> Raises: +> fitbit_client.exceptions.PaginationException: If neither before_date nor after_date is specified +> fitbit_client.exceptions.PaginationException: If offset is not 0 +> fitbit_client.exceptions.PaginationException: If limit exceeds 10 +> fitbit_client.exceptions.PaginationException: If sort direction doesn't match date parameter +> (must use ASCENDING with after_date, DESCENDING with before_date) +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Either before_date or after_date must be specified, but not both +> - The offset parameter only supports 0; use the "next" URL in the pagination response +> to iterate through results +> - waveformSamples contains the actual ECG data points (300 samples per second) +> - 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} + +> if before_date: +> params["beforeDate"] = before_date +> if after_date: +> params["afterDate"] = after_date + +> response = self._make_request("ecg/list.json", params=params, user_id=user_id, debug=debug) +> return cast(JSONDict, response) diff --git a/fitbit_client/resources/friends.py,cover b/fitbit_client/resources/friends.py,cover new file mode 100644 index 0000000..a56b788 --- /dev/null +++ b/fitbit_client/resources/friends.py,cover @@ -0,0 +1,104 @@ + # fitbit_client/resources/friends.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.utils.types import JSONDict + + +> class FriendsResource(BaseResource): +> """Provides access to Fitbit Friends API for retrieving social connections and leaderboards. + +> This resource handles endpoints for accessing a user's friends list and leaderboard data, +> which shows step count rankings among connected users. The Friends API allows applications +> to display social features like friend lists and competitive step count rankings. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/friends/ + +> Required Scopes: +> - social: Required for all endpoints in this resource + +> Note: +> The Fitbit privacy setting 'My Friends' (Private, Friends Only, or Public) determines +> the access to a user's list of friends. Similarly, the 'Average Daily Step Count' +> privacy setting affects whether a user appears on leaderboards. + +> This scope does not provide access to friends' Fitbit activity data - those users need to +> individually consent to share their data with your application. + +> This resource uses API version 1.1 instead of the standard version 1. +> """ + +> API_VERSION: str = "1.1" + +> def get_friends(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns a list of the specified Fitbit user's friends. + +> This endpoint retrieves all social connections (friends) for a Fitbit user. It returns +> basic profile information for each friend, including their display name and profile picture. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/friends/get-friends/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: List of the user's Fitbit friends with basic profile information + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If the required scope is not granted +> fitbit_client.exceptions.ForbiddenException: If user's privacy settings restrict access + +> Note: +> The user's privacy settings ('My Friends') determine whether this data is accessible. +> Access may be restricted based on whether the setting is Private, Friends Only, or Public. + +> This endpoint uses API version 1.1, which has a different response format compared to +> most other Fitbit API endpoints. +> """ +> result = self._make_request( +> "friends.json", user_id=user_id, api_version=FriendsResource.API_VERSION, debug=debug +> ) +> return cast(JSONDict, result) + +> def get_friends_leaderboard(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns the user's friends leaderboard showing step counts for the last 7 days. + +> This endpoint retrieves a ranked list of the user and their friends based on step counts +> over the past 7 days (previous 6 days plus current day in real time). This can be used +> to display competitive step count rankings among connected users. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/friends/get-friends-leaderboard/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Ranked list of the user and their friends based on step counts over the past 7 days + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If the required scope is not granted +> fitbit_client.exceptions.ForbiddenException: If user's privacy settings restrict access + +> Note: +> - The leaderboard includes data for the previous 6 days plus the current day in real time +> - The authorized user (self) is included in the response +> - The 'Average Daily Step Count' privacy setting affects whether users appear on leaderboards +> - Both active ('ranked-user') and inactive ('inactive-user') friends are included +> - Inactive users have no step-rank or step-summary values +> - The 'included' section provides profile information for all users in the 'data' section + +> This endpoint uses API version 1.1, which has a different response format compared to +> most other Fitbit API endpoints. +> """ +> result = self._make_request( +> "leaderboard/friends.json", +> user_id=user_id, +> api_version=FriendsResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/heartrate_timeseries.py,cover b/fitbit_client/resources/heartrate_timeseries.py,cover new file mode 100644 index 0000000..1acb571 --- /dev/null +++ b/fitbit_client/resources/heartrate_timeseries.py,cover @@ -0,0 +1,179 @@ + # fitbit_client/resources/heartrate_timeseries.py + + # Standard library imports +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import IntradayValidationException +> from fitbit_client.exceptions import ParameterValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import Period +> 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 + + +> class HeartrateTimeSeriesResource(BaseResource): +> """Provides access to Fitbit Heart Rate Time Series API for retrieving heart rate data. + +> This resource handles endpoints for retrieving daily heart rate summaries including +> heart rate zones, resting heart rate, and time spent in each zone. It provides data +> for specific dates or date ranges. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/ + +> Required Scopes: heartrate + +> Note: +> Data availability is limited to the user's join date or first log entry date. +> Responses include daily summary values but not minute-by-minute data. +> For intraday (minute-level) heart rate data, use the IntradayResource class. +> This resource requires a heart rate capable Fitbit device. +> """ + +> @validate_date_param(field_name="date") +> def get_heartrate_timeseries_by_date( +> self, +> date: str, +> period: Period, +> user_id: str = "-", +> timezone: Optional[str] = None, +> debug: bool = False, +> ) -> JSONDict: +> """Returns heart rate time series data for a period ending on the specified date. + +> This endpoint retrieves daily heart rate summaries for a specified time period ending +> on the given date. The data includes resting heart rate and time spent in different +> heart rate zones for each day in the period. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/get-heartrate-timeseries-by-date/ + +> Args: +> date: The end date in YYYY-MM-DD format or 'today' +> period: Time period to retrieve data for (must be one of: Period.ONE_DAY, +> Period.SEVEN_DAYS, Period.THIRTY_DAYS, Period.ONE_WEEK, Period.ONE_MONTH) +> user_id: Optional user ID, defaults to current user ("-") +> timezone: Optional timezone (only 'UTC' supported) +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate data for each day in the period, including heart rate zones and resting heart rate + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If period is not one of the supported period values +> fitbit_client.exceptions.ParameterValidationException: If timezone is provided and not 'UTC' +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If the required scope is not granted + +> Note: +> Resting heart rate is calculated from measurements throughout the day, +> prioritizing sleep periods. If insufficient data exists for a day, +> the restingHeartRate field may be missing from that day's data. + +> Each heart rate zone contains: +> - name: Zone name (Out of Range, Fat Burn, Cardio, Peak) +> - min/max: The heart rate boundaries for this zone in beats per minute +> - minutes: Total time spent in this zone in minutes +> - caloriesOut: Estimated calories burned while in this zone + +> The standard zones are calculated based on the user's profile data (age, gender, etc.) +> and represent different exercise intensities: +> - Out of Range: Below 50% of max heart rate +> - Fat Burn: 50-69% of max heart rate +> - Cardio: 70-84% of max heart rate +> - Peak: 85-100% of max heart rate +> """ +> supported_periods = { +> Period.ONE_DAY, +> Period.SEVEN_DAYS, +> Period.THIRTY_DAYS, +> Period.ONE_WEEK, +> Period.ONE_MONTH, +> } + +> if period not in supported_periods: +> raise IntradayValidationException( +> message=f"Period must be one of the supported values", +> field_name="period", +> allowed_values=[p.value for p in supported_periods], +> resource_name="heart rate", +> ) + +> if timezone is not None and timezone != "UTC": +> raise ParameterValidationException( +> message="Only 'UTC' timezone is supported", field_name="timezone" +> ) + +> params = {"timezone": timezone} if timezone else None +> result = self._make_request( +> f"activities/heart/date/{date}/{period.value}.json", +> params=params, +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params() +> def get_heartrate_timeseries_by_date_range( +> self, +> start_date: str, +> end_date: str, +> user_id: str = "-", +> timezone: Optional[str] = None, +> debug: bool = False, +> ) -> JSONDict: +> """Returns heart rate time series data for a specified date range. + +> This endpoint retrieves daily heart rate summaries for each day in the specified date range. +> The data includes resting heart rate and time spent in different heart rate zones for each +> day in the range. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/get-heartrate-timeseries-by-date-range/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> timezone: Optional timezone (only 'UTC' supported) +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate data for each day in the date range, including heart rate zones and resting heart rate + +> Raises: +> fitbit_client.exceptions.ParameterValidationException: If timezone is provided and not 'UTC' +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date or +> if the date range exceeds the maximum allowed (1095 days) +> fitbit_client.exceptions.AuthorizationException: If the required scope is not granted + +> Note: +> Maximum date range is 1095 days (approximately 3 years). + +> Resting heart rate is calculated from measurements throughout the day, +> prioritizing sleep periods. If insufficient data exists for a particular day, +> the restingHeartRate field may be missing from that day's data. + +> Each heart rate zone contains: +> - name: Zone name (Out of Range, Fat Burn, Cardio, Peak) +> - min/max: The heart rate boundaries for this zone in beats per minute +> - minutes: Total time spent in this zone in minutes +> - caloriesOut: Estimated calories burned while in this zone + +> This endpoint returns the same data format as the get_heartrate_timeseries_by_date +> method, but allows for more precise control over the date range. +> """ +> if timezone is not None and timezone != "UTC": +> raise ParameterValidationException( +> message="Only 'UTC' timezone is supported", field_name="timezone" +> ) + +> params = {"timezone": timezone} if timezone else None +> result = self._make_request( +> f"activities/heart/date/{start_date}/{end_date}.json", +> params=params, +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/heartrate_variability.py,cover b/fitbit_client/resources/heartrate_variability.py,cover new file mode 100644 index 0000000..a9bba65 --- /dev/null +++ b/fitbit_client/resources/heartrate_variability.py,cover @@ -0,0 +1,122 @@ + # fitbit_client/resources/heartrate_variability.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> 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 + + +> class HeartrateVariabilityResource(BaseResource): +> """Provides access to Fitbit Heart Rate Variability (HRV) API for retrieving daily metrics. + +> This resource handles endpoints for retrieving HRV measurements taken during sleep, which +> is a key indicator of recovery, stress, and overall autonomic nervous system health. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-variability/ + +> Required Scopes: +> - heartrate: Required for all endpoints in this resource + +> Note: +> Heart Rate Variability (HRV) measures the variation in time between consecutive +> heartbeats. High HRV generally indicates better cardiovascular fitness, stress +> resilience, and recovery capacity. Low HRV may indicate stress, fatigue, or illness. + +> HRV is calculated using the RMSSD (Root Mean Square of Successive Differences) +> measurement in milliseconds. Typical healthy adult values range from 20-100ms, +> with higher values generally indicating better autonomic function. + +> HRV data collection requirements: +> - Enabled Health Metrics tile in mobile app dashboard +> - Minimum 3 hours of sleep +> - Creation of sleep stages log during main sleep period +> - Device compatibility (check Fitbit Product page for supported devices) +> - No Premium subscription required + +> Data processing occurs after device sync and takes ~15 minutes to become available. +> HRV measurements span sleep periods that may begin on the previous day. +> """ + +> @validate_date_param(field_name="date") +> def get_hrv_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves HRV summary data for a single date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-variability/get-hrv-summary-by-date/ + +> Args: +> date: Date in YYYY-MM-DD format or 'today' +> user_id: The encoded ID of the user. Use "-" (dash) for current logged-in user. +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: HRV data containing daily and deep sleep RMSSD measurements for the requested date + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Data reflects the main sleep period, which may have started the previous day. +> The response may be empty if no HRV data was collected for the requested date. + +> Two RMSSD values are provided: +> - dailyRmssd: Calculated across all sleep stages during the main sleep session +> - deepRmssd: Calculated only during deep sleep, which typically shows lower +> HRV values due to increased parasympathetic nervous system dominance + +> For reliable data collection, users should wear their device properly and +> remain still during sleep measurement. Environmental factors like alcohol, +> caffeine, or stress can affect HRV measurements. +> """ +> result = self._make_request(f"hrv/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params() +> def get_hrv_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves HRV summary data for a date range. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/heartrate-variability/get-hrv-summary-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: The encoded ID of the user. Use "-" (dash) for current logged-in user. +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: HRV data containing daily and deep sleep RMSSD measurements for multiple dates in the requested range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Maximum date range is 30 days. Requests exceeding this limit will return an error. + +> The response includes entries only for dates where HRV data was collected. +> Dates without data will not appear in the results array. + +> HRV data is calculated from sleep sessions, which may have started on the +> previous day. The dateTime field represents the date the sleep session +> ended, not when it began. + +> Tracking HRV over time provides insights into: +> - Recovery status and adaptation to training +> - Potential early warning signs of overtraining or illness +> - Effects of lifestyle changes on autonomic nervous system function + +> For optimal trend analysis, collect data consistently at the same time each day. +> """ +> result = self._make_request( +> f"hrv/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/intraday.py,cover b/fitbit_client/resources/intraday.py,cover new file mode 100644 index 0000000..de6cc95 --- /dev/null +++ b/fitbit_client/resources/intraday.py,cover @@ -0,0 +1,834 @@ + # fitbit_client/resources/intraday.py + + # Standard library imports +> from logging import getLogger +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import IntradayValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import IntradayDetailLevel +> from fitbit_client.resources.constants import MaxRanges +> 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 + + +> class IntradayResource(BaseResource): +> """Provides access to Fitbit Intraday API for retrieving detailed within-day time series data. + +> This resource handles endpoints for retrieving minute-by-minute or second-by-second data +> for various metrics including activity (steps, calories), heart rate, SpO2, breathing rate, +> heart rate variability (HRV), and active zone minutes. The intraday data provides much more +> granular insights than the daily summary endpoints. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/ + +> Required Scopes: +> - activity: For activity-related intraday data +> - heartrate: For heart rate intraday data +> - respiratory_rate: For breathing rate intraday data +> - oxygen_saturation: For SpO2 intraday data +> - cardio_fitness: For heart rate variability intraday data + +> Note: +> OAuth 2.0 Application Type must be set to "Personal" to use intraday data. +> All other application types require special approval from Fitbit. + +> Intraday data is much more detailed than daily summaries and has strict +> limitations on date ranges (usually 24 hours or 30 days maximum). + +> Different metrics support different granularity levels. For example, +> heart rate data is available at 1-second or 1-minute intervals, while +> activity data is available at 1-minute, 5-minute, or 15-minute intervals. +> """ + +> @validate_date_param(field_name="date") +> def get_azm_intraday_by_date( +> self, +> date: str, +> detail_level: IntradayDetailLevel, +> start_time: Optional[str] = None, +> end_time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves intraday active zone minutes time series data for a single date. + +> This endpoint provides minute-by-minute active zone minutes data, showing the +> intensity of activity throughout the day. Active Zone Minutes are earned when +> in the fat burn, cardio, or peak heart rate zones. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-azm-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> detail_level: Level of detail (ONE_MINUTE, FIVE_MINUTES, or FIFTEEN_MINUTES) +> start_time: Optional start time in HH:mm format to limit the time window +> end_time: Optional end time in HH:mm format to limit the time window +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Active zone minutes data containing daily summary and intraday time series + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level is invalid +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Active Zone Minutes measure time spent in heart rate zones that count toward +> your weekly goals. Different detail levels change the granularity of the data: +> - ONE_MINUTE (1min): Shows minute-by-minute values +> - FIVE_MINUTES (5min): Shows values averaged over 5-minute intervals +> - FIFTEEN_MINUTES (15min): Shows values averaged over 15-minute intervals + +> The "activities-active-zone-minutes" section contains daily summary data, +> while the "intraday" section contains the detailed time-specific data. + +> AZM values are categorized by intensity zones: +> - Fat Burn: Moderate intensity (1 Active Zone Minute per minute) +> - Cardio: High intensity (2 Active Zone Minutes per minute) +> - Peak: Very high intensity (2 Active Zone Minutes per minute) + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> valid_levels = [ +> IntradayDetailLevel.ONE_MINUTE, +> IntradayDetailLevel.FIVE_MINUTES, +> IntradayDetailLevel.FIFTEEN_MINUTES, +> ] +> if detail_level not in valid_levels: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in valid_levels], +> resource_name="active zone minutes", +> ) + +> endpoint = f"activities/active-zone-minutes/date/{date}/1d/{detail_level.value}" +> if start_time and end_time: +> endpoint += f"/time/{start_time}/{end_time}" +> endpoint += ".json" + +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params( +> max_days=MaxRanges.INTRADAY, resource_name="active zone minutes intraday" +> ) +> def get_azm_intraday_by_interval( +> self, +> start_date: str, +> end_date: str, +> detail_level: IntradayDetailLevel, +> start_time: Optional[str] = None, +> end_time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves intraday active zone minutes time series data for a date range. + +> This endpoint provides minute-by-minute active zone minutes data across multiple days, +> showing the intensity of activity throughout the specified period. The maximum date +> range is limited to 24 hours. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-azm-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> detail_level: Level of detail (ONE_MINUTE, FIVE_MINUTES, or FIFTEEN_MINUTES) +> start_time: Optional start time in HH:mm format to limit the time window +> end_time: Optional end time in HH:mm format to limit the time window +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Active zone minutes data containing daily summaries and intraday time series + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level is invalid +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 24 hours +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Important limitations: +> - Maximum date range is 24 hours (1 day), even if start_date and end_date differ by more +> - For longer periods, make multiple requests with consecutive date ranges + +> The different detail levels change the granularity of the data: +> - ONE_MINUTE (1min): Shows minute-by-minute values +> - FIVE_MINUTES (5min): Shows values averaged over 5-minute intervals +> - FIFTEEN_MINUTES (15min): Shows values averaged over 15-minute intervals + +> The time window parameters (start_time/end_time) can be useful to limit the +> amount of data returned, especially when you're only interested in activity +> during specific hours of the day. + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> valid_levels = [ +> IntradayDetailLevel.ONE_MINUTE, +> IntradayDetailLevel.FIVE_MINUTES, +> IntradayDetailLevel.FIFTEEN_MINUTES, +> ] +> if detail_level not in valid_levels: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in valid_levels], +> resource_name="active zone minutes", +> ) + +> endpoint = ( +> f"activities/active-zone-minutes/date/{start_date}/{end_date}/{detail_level.value}" +> ) +> if start_time and end_time: +> endpoint += f"/time/{start_time}/{end_time}" +> endpoint += ".json" + +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_param(field_name="date") +> def get_activity_intraday_by_date( +> self, +> date: str, +> resource_path: str, +> detail_level: IntradayDetailLevel, +> start_time: Optional[str] = None, +> end_time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves intraday activity time series data for a single date. + +> This endpoint provides detailed activity metrics (steps, calories, distance, etc.) +> at regular intervals throughout the day, allowing analysis of activity patterns +> with much greater precision than daily summaries. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-activity-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> resource_path: The activity metric to fetch (e.g., "steps", "calories", "distance") +> detail_level: Level of detail (ONE_MINUTE, FIVE_MINUTES, or FIFTEEN_MINUTES) +> start_time: Optional start time in HH:mm format to limit the time window +> end_time: Optional end time in HH:mm format to limit the time window +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity data with daily summary and intraday time series for the specified metric + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level or resource_path is invalid +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Valid resource_path options: +> - "calories": Calories burned per interval +> - "steps": Step count per interval +> - "distance": Distance covered per interval (in miles or kilometers) +> - "floors": Floors climbed per interval +> - "elevation": Elevation change per interval (in feet or meters) +> - "swimming-strokes": Swimming strokes per interval + +> Different detail levels change the granularity of the data: +> - ONE_MINUTE (1min): Shows minute-by-minute values +> - FIVE_MINUTES (5min): Shows values averaged or summed over 5-minute intervals +> - FIFTEEN_MINUTES (15min): Shows values averaged or summed over 15-minute intervals + +> The response format changes based on the resource_path, with the appropriate +> field names ("activities-steps", "activities-calories", etc.), but the +> overall structure remains the same. + +> Activity units are based on the user's profile settings: +> - Imperial: miles, feet +> - Metric: kilometers, meters + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> valid_resources = { +> "calories", +> "distance", +> "elevation", +> "floors", +> "steps", +> "swimming-strokes", +> } +> if resource_path not in valid_resources: +> raise IntradayValidationException( +> message="Invalid resource path", +> field_name="resource_path", +> allowed_values=sorted(list(valid_resources)), +> resource_name="activity", +> ) + +> valid_levels = [ +> IntradayDetailLevel.ONE_MINUTE, +> IntradayDetailLevel.FIVE_MINUTES, +> IntradayDetailLevel.FIFTEEN_MINUTES, +> ] +> if detail_level not in valid_levels: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in valid_levels], +> resource_name="activity", +> ) + +> endpoint = f"activities/{resource_path}/date/{date}/1d/{detail_level.value}" +> if start_time and end_time: +> endpoint += f"/time/{start_time}/{end_time}" +> endpoint += ".json" + +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params(max_days=MaxRanges.INTRADAY, resource_name="activity intraday") +> def get_activity_intraday_by_interval( +> self, +> start_date: str, +> end_date: str, +> resource_path: str, +> detail_level: IntradayDetailLevel, +> start_time: Optional[str] = None, +> end_time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves intraday activity time series data for a date range. + +> This endpoint provides detailed activity metrics across multiple days, with the +> same level of granularity as the single-date endpoint. The maximum date range +> is limited to 24 hours to keep response sizes manageable. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-activity-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> resource_path: The activity metric to fetch (e.g., "steps", "calories", "distance") +> detail_level: Level of detail (ONE_MINUTE, FIVE_MINUTES, or FIFTEEN_MINUTES) +> start_time: Optional start time in HH:mm format to limit the time window +> end_time: Optional end time in HH:mm format to limit the time window +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Activity data with daily summaries and intraday time series for the specified metric + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level or resource_path is invalid +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 24 hours +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Important limitations: +> - Maximum date range is 24 hours (1 day), even if start_date and end_date differ by more +> - For longer periods, make multiple requests with consecutive date ranges + +> Valid resource_path options: +> - "calories": Calories burned per interval +> - "steps": Step count per interval +> - "distance": Distance covered per interval (in miles or kilometers) +> - "floors": Floors climbed per interval +> - "elevation": Elevation change per interval (in feet or meters) +> - "swimming-strokes": Swimming strokes per interval + +> Different detail levels change the granularity of the data: +> - ONE_MINUTE (1min): Shows minute-by-minute values +> - FIVE_MINUTES (5min): Shows values averaged or summed over 5-minute intervals +> - FIFTEEN_MINUTES (15min): Shows values averaged or summed over 15-minute intervals + +> The response format will differ based on the resource_path, but the overall +> structure remains the same. + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> valid_resources = { +> "calories", +> "distance", +> "elevation", +> "floors", +> "steps", +> "swimming-strokes", +> } +> if resource_path not in valid_resources: +> raise IntradayValidationException( +> message="Invalid resource path", +> field_name="resource_path", +> allowed_values=sorted(list(valid_resources)), +> resource_name="activity", +> ) + +> valid_levels = [ +> IntradayDetailLevel.ONE_MINUTE, +> IntradayDetailLevel.FIVE_MINUTES, +> IntradayDetailLevel.FIFTEEN_MINUTES, +> ] +> if detail_level not in IntradayDetailLevel: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in valid_levels], +> resource_name="activity", +> ) + +> endpoint = f"activities/{resource_path}/date/{start_date}/{end_date}/{detail_level.value}" +> if start_time and end_time: +> endpoint += f"/time/{start_time}/{end_time}" +> endpoint += ".json" + +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_param(field_name="date") +> def get_breathing_rate_intraday_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday breathing rate data for a single date. + +> This endpoint returns detailed breathing rate measurements recorded during sleep. +> Breathing rate data provides insights into respiratory health, sleep quality, +> and potential health issues. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-br-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Breathing rate data including summary and detailed measurements during sleep + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Breathing rate data is collected during sleep periods and is measured in +> breaths per minute (BPM). Typical adult resting breathing rates range from +> 12-20 breaths per minute. + +> The data is collected in approximately 15-minute intervals during sleep. +> Each measurement includes a confidence level indicating the reliability +> of the reading. + +> Different sleep stages normally have different breathing rates: +> - Deep sleep: Typically slower, more regular breathing +> - REM sleep: Variable breathing rate, may be faster or more irregular + +> Breathing rate data requires a compatible Fitbit device with the appropriate +> sensors and the Health Metrics Dashboard enabled in the Fitbit app. + +> This data is associated with the date the sleep ends, even if the sleep +> session began on the previous day. +> """ +> endpoint = f"br/date/{date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params(max_days=30, resource_name="breathing rate intraday") +> def get_breathing_rate_intraday_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday breathing rate data for a date range. + +> This endpoint returns detailed breathing rate measurements recorded during sleep +> across multiple days, up to a maximum range of 30 days. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-br-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Breathing rate data including daily summaries and detailed measurements during sleep + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 30 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The maximum date range for this endpoint is 30 days. For longer historical +> periods, you will need to make multiple requests with different date ranges. + +> Breathing rate data is collected during sleep periods and is measured in +> breaths per minute (BPM). The returned data includes: +> - Daily summary values for different sleep stages +> - Detailed intraday measurements throughout each sleep session + +> Each day's data is associated with the date the sleep ends, even if the sleep +> session began on the previous day. + +> This endpoint requires the "respiratory_rate" OAuth scope and a compatible +> Fitbit device with the appropriate sensors and the Health Metrics Dashboard +> enabled in the Fitbit app. + +> Analyzing breathing rate trends over time can provide insights into: +> - Sleep quality and patterns +> - Recovery from exercise or illness +> - Potential respiratory issues +> """ + +> endpoint = f"br/date/{start_date}/{end_date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_param(field_name="date") +> def get_heartrate_intraday_by_date( +> self, +> date: str, +> detail_level: IntradayDetailLevel, +> start_time: Optional[str] = None, +> end_time: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns detailed heart rate data at minute or second intervals for a single date. + +> This endpoint retrieves heart rate measurements at the specified granularity (detail level) +> for a specific date. It can optionally be limited to a specific time window within the day. +> This provides much more detailed heart rate data than the daily summary endpoints. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-heartrate-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> detail_level: Level of detail (IntradayDetailLevel.ONE_SECOND or IntradayDetailLevel.ONE_MINUTE) +> start_time: Optional start time in HH:mm format to limit the time window +> end_time: Optional end time in HH:mm format to limit the time window +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate data including daily summary and detailed time series + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level is invalid +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The "activities-heart" section contains the same data as the daily heart rate summary. +> The "activities-heart-intraday" section contains the detailed, minute-by-minute or +> second-by-second heart rate measurements. + +> For one-second detail level (1sec), the dataset can be very large, potentially +> containing up to 86,400 data points for a full day. For applications handling +> large volumes of data, consider using time windows (start_time/end_time) +> to limit the response size. + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> if detail_level not in IntradayDetailLevel: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in IntradayDetailLevel], +> resource_name="heart rate", +> ) + +> endpoint = f"activities/heart/date/{date}/1d/{detail_level.value}" +> if start_time and end_time: +> endpoint += f"/time/{start_time}/{end_time}" +> endpoint += ".json" + +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params() +> def get_heartrate_intraday_by_interval( +> self, +> start_date: str, +> end_date: str, +> detail_level: IntradayDetailLevel = IntradayDetailLevel.ONE_MINUTE, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves intraday heart rate time series data for a date range. + +> This endpoint provides second-by-second or minute-by-minute heart rate measurements +> across multiple days. This allows for detailed analysis of heart rate patterns +> and trends over extended periods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-heartrate-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> detail_level: Level of detail (ONE_SECOND or ONE_MINUTE, default: ONE_MINUTE) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate data including daily summaries and detailed time series + +> Raises: +> fitbit_client.exceptions.IntradayValidationException: If detail_level is invalid +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> This endpoint supports two levels of detail: +> - ONE_SECOND (1sec): Heart rate readings every second, providing maximum granularity +> - ONE_MINUTE (1min): Heart rate readings every minute, for more manageable data size + +> For ONE_SECOND detail level, the response can be extremely large for longer +> date ranges, potentially containing up to 86,400 data points per day. Consider +> using ONE_MINUTE detail level unless you specifically need second-level detail. + +> Unlike most other intraday endpoints, there is no explicit maximum date range +> for this endpoint. However, requesting too much data at once can result in +> timeouts or very large responses. For best performance, limit requests to +> a few days at a time, especially with ONE_SECOND detail level. + +> Heart rate data is recorded continuously when a compatible Fitbit device +> is worn, with gaps during times when the device is not worn or cannot +> get a reliable reading. + +> Personal applications automatically have access to intraday data. +> Other application types require special approval from Fitbit. +> """ +> valid_levels = [IntradayDetailLevel.ONE_SECOND, IntradayDetailLevel.ONE_MINUTE] +> if detail_level not in valid_levels: +> raise IntradayValidationException( +> message="Invalid detail level", +> field_name="detail_level", +> allowed_values=[l.value for l in valid_levels], +> resource_name="heart rate", +> ) + +> endpoint = f"activities/heart/date/{start_date}/{end_date}/{detail_level.value}.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_param(field_name="date") +> def get_hrv_intraday_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday heart rate variability (HRV) data for a single date. + +> This endpoint returns detailed heart rate variability measurements taken during sleep. +> HRV is a key indicator of autonomic nervous system health, stress levels, and recovery. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-hrv-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate variability data including daily summary and detailed measurements + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> HRV data is collected specifically during the user's "main sleep" period +> (typically the longest sleep of the day). Key information: + +> - RMSSD (Root Mean Square of Successive Differences): The primary HRV +> metric measured in milliseconds. Higher values typically indicate better +> recovery and lower stress levels. Normal adult ranges vary widely from +> approximately 20-100ms. + +> - Data is collected in 5-minute intervals during sleep. + +> - HF (High Frequency) power: Associated with parasympathetic nervous system +> activity (rest and recovery). + +> - LF (Low Frequency) power: Influenced by both sympathetic (stress response) +> and parasympathetic nervous systems. + +> - Coverage: Indicates the quality of the data collection during each interval. + +> Requirements for HRV data collection: +> - Health Metrics tile enabled in the Fitbit mobile app +> - Minimum 3 hours of sleep +> - Sleep stages log creation (depends on device having heart rate sensor) +> - Compatible Fitbit device + +> Data processing takes approximately 15 minutes after device sync. +> The date represents when the sleep ended, even if it began on the previous day. +> """ +> endpoint = f"hrv/date/{date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params(max_days=30, resource_name="heart rate variability intraday") +> def get_hrv_intraday_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday heart rate variability (HRV) data for a date range. + +> This endpoint returns detailed heart rate variability measurements taken during +> sleep across multiple days, up to a maximum range of 30 days. This is useful for +> analyzing trends in recovery and stress levels over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-hrv-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Heart rate variability data including daily summaries and detailed measurements + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 30 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The maximum date range for this endpoint is 30 days. For longer historical +> periods, you will need to make multiple requests with different date ranges. + +> HRV data is collected specifically during the user's "main sleep" period +> each day. The data includes: +> - Daily summary values (dailyRmssd, deepRmssd) +> - Detailed 5-minute interval measurements throughout each sleep session + +> RMSSD (Root Mean Square of Successive Differences) is measured in milliseconds, +> with higher values typically indicating better recovery and lower stress levels. + +> Analyzing HRV trends over time can provide insights into: +> - Recovery status and adaptation to training +> - Stress levels and potential burnout +> - Sleep quality +> - Overall autonomic nervous system balance + +> Requirements for HRV data collection: +> - Health Metrics tile enabled in the Fitbit mobile app +> - Minimum 3 hours of sleep each night +> - Sleep stages log creation (requires heart rate sensor) +> - Compatible Fitbit device + +> Each day's data is associated with the date the sleep ends, even if the sleep +> session began on the previous day. +> """ +> endpoint = f"hrv/date/{start_date}/{end_date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_param(field_name="date") +> def get_spo2_intraday_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday SpO2 (blood oxygen saturation) data for a single date. + +> This endpoint returns detailed SpO2 measurements taken during sleep. Blood oxygen +> saturation is an important health metric that reflects how well the body is +> supplying oxygen to the blood. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-spo2-intraday-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: SpO2 data including daily summary and detailed measurements + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> SpO2 (Blood Oxygen Saturation) data is collected during the user's "main sleep" +> period (typically the longest sleep of the day). Key information: + +> - SpO2 is measured as a percentage, with normal values typically ranging +> from 95-100% for healthy individuals at rest. + +> - Values below 90% may indicate potential health concerns, though Fitbit +> devices are not medical devices and should not be used for diagnosis. + +> - Data is calculated using a 5-minute exponentially-moving average to +> smooth out short-term fluctuations. + +> - Measurements are taken approximately every 5 minutes during sleep. + +> Requirements for SpO2 data collection: +> - Minimum 3 hours of quality sleep +> - Limited physical movement during sleep +> - Compatible Fitbit device with SpO2 monitoring capabilities +> - SpO2 tracking enabled in device settings + +> Data processing can take up to 1 hour after device sync. +> The date represents when the sleep ended, even if it began on the previous day. +> """ +> endpoint = f"spo2/date/{date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) + +> @validate_date_range_params(max_days=30, resource_name="spo2 intraday") +> def get_spo2_intraday_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves intraday SpO2 (blood oxygen saturation) data for a date range. + +> This endpoint returns detailed SpO2 measurements taken during sleep across +> multiple days, up to a maximum range of 30 days. This is useful for +> analyzing trends in blood oxygen levels over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/intraday/get-spo2-intraday-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: SpO2 data including daily summaries and detailed measurements + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date formats are invalid +> fitbit_client.exceptions.InvalidDateRangeException: If date range is invalid or exceeds 30 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The maximum date range for this endpoint is 30 days. For longer historical +> periods, you will need to make multiple requests with different date ranges. + +> SpO2 (Blood Oxygen Saturation) data is collected during each day's "main sleep" +> period. The data includes: +> - Daily summary values (average, minimum, maximum) +> - Detailed measurements taken approximately every 5 minutes during sleep + +> SpO2 is measured as a percentage, with normal values typically ranging +> from 95-100% for healthy individuals at rest. Consistent readings below 95% +> might warrant discussion with a healthcare provider, though Fitbit devices +> are not medical devices and should not be used for diagnosis. + +> Analyzing SpO2 trends over time can provide insights into: +> - Sleep quality +> - Respiratory health +> - Altitude acclimation +> - Potential sleep-related breathing disorders + +> Requirements for SpO2 data collection: +> - Minimum 3 hours of quality sleep each night +> - Limited physical movement during sleep +> - Compatible Fitbit device with SpO2 monitoring capabilities +> - SpO2 tracking enabled in device settings + +> Each day's data is associated with the date the sleep ends, even if the sleep +> session began on the previous day. +> """ +> endpoint = f"spo2/date/{start_date}/{end_date}/all.json" +> return cast(JSONDict, self._make_request(endpoint, user_id=user_id, debug=debug)) diff --git a/fitbit_client/resources/irregular_rhythm_notifications.py,cover b/fitbit_client/resources/irregular_rhythm_notifications.py,cover new file mode 100644 index 0000000..8b30e04 --- /dev/null +++ b/fitbit_client/resources/irregular_rhythm_notifications.py,cover @@ -0,0 +1,137 @@ + # fitbit_client/resources/irregular_rhythm_notifications.py + + # Standard library imports +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import SortDirection +> 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 + + +> class IrregularRhythmNotificationsResource(BaseResource): +> """Provides access to Fitbit Irregular Rhythm Notifications (IRN) API for heart rhythm monitoring. + +> This resource handles endpoints for retrieving Irregular Rhythm Notifications (IRN), +> which are alerts sent to users when their device detects signs of atrial fibrillation (AFib). +> The API can be used to access notification history and user enrollment status. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/irregular-rhythm-notifications/ + +> Required Scopes: +> - irregular_rhythm_notifications (for all IRN endpoints) + +> Important: +> The IRN API is for research use or investigational use only, and is not intended +> for clinical or diagnostic purposes. IRN results do not replace traditional diagnosis +> methods and should not be interpreted as medical advice. + +> Note: +> - Only alerts that have been read by the user in the Fitbit app are accessible +> - IRN does not support subscription notifications (webhooks) +> - Data becomes available after device sync and user interaction with notifications +> - IRN requires a compatible Fitbit device with heart rate monitoring capabilities +> - Users must complete an on-device enrollment flow to enable the IRN feature +> - Notifications are analyzed based on heart rate data collected during sleep +> - IRN is not a continuous monitoring system and is not designed to detect heart attacks +> """ + +> @validate_date_param(field_name="before_date") +> @validate_date_param(field_name="after_date") +> @validate_pagination_params(max_limit=10) +> def get_irn_alerts_list( +> self, +> before_date: Optional[str] = None, +> after_date: Optional[str] = None, +> sort: SortDirection = SortDirection.DESCENDING, +> limit: int = 10, +> offset: int = 0, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns a paginated list of Irregular Rhythm Notifications (IRN) alerts. + +> This endpoint retrieves alerts generated when the user's device detected signs of +> possible atrial fibrillation (AFib). Only alerts that have been viewed by the user +> in their Fitbit app will be returned. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/irregular-rhythm-notifications/get-irn-alerts-list/ + +> Args: +> before_date: Return entries before this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> after_date: Return entries after this date (YYYY-MM-DD or 'today'). +> You can optionally include time in ISO 8601 format (YYYY-MM-DDThh:mm:ss). +> sort: Sort order - must use SortDirection.ASCENDING with after_date and +> SortDirection.DESCENDING with before_date (default: DESCENDING) +> limit: Number of entries to return (max 10, default: 10) +> offset: Pagination offset (only 0 is supported by the Fitbit API) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Contains IRN alerts and pagination information for the requested period + +> Raises: +> fitbit_client.exceptions.PaginationException: If neither before_date nor after_date is specified +> fitbit_client.exceptions.PaginationException: If offset is not 0 +> fitbit_client.exceptions.PaginationException: If limit exceeds 10 +> fitbit_client.exceptions.PaginationException: If sort direction doesn't match date parameter +> (must use ASCENDING with after_date, DESCENDING with before_date) +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Either before_date or after_date must be specified, but not both +> - The offset parameter only supports 0; use the "next" URL in the pagination response +> to iterate through results +> - Tachogram data represents the time between heartbeats in milliseconds +> - The algorithm analyzes heart rate irregularity patterns during sleep +> - For research purposes only, not for clinical or diagnostic use +> - 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} + +> if before_date: +> params["beforeDate"] = before_date +> if after_date: +> params["afterDate"] = after_date + +> result = self._make_request( +> "irn/alerts/list.json", params=params, user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> def get_irn_profile(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns the user's Irregular Rhythm Notifications (IRN) feature engagement status. + +> This endpoint retrieves information about the user's enrollment status for the +> Irregular Rhythm Notifications feature, including whether they've completed the +> required onboarding process and when their data was last analyzed. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/irregular-rhythm-notifications/get-irn-profile/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: User's IRN feature engagement status including onboarding and enrollment information + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If missing the irregular_rhythm_notifications scope +> fitbit_client.exceptions.InvalidRequestException: If the user is not eligible for IRN + +> Note: +> - "onboarded": True if the user has completed the IRN feature onboarding process +> - "enrolled": True if the user is actively enrolled and receiving notifications +> - "lastUpdated": Timestamp of when analyzable data was last synced to Fitbit servers +> - Users must complete an on-device onboarding flow to enable IRN +> - Enrollment can be paused/resumed by the user in their Fitbit app settings +> - Analyzing the data requires a compatible device, sufficient sleep data, and proper wear +> """ +> result = self._make_request("irn/profile.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/nutrition.py,cover b/fitbit_client/resources/nutrition.py,cover new file mode 100644 index 0000000..d5908d0 --- /dev/null +++ b/fitbit_client/resources/nutrition.py,cover @@ -0,0 +1,1241 @@ + # fitbit_client/resources/nutrition.py + + # Standard library imports +> from typing import Dict +> from typing import List +> from typing import Optional +> from typing import Union +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import ClientValidationException +> from fitbit_client.exceptions import MissingParameterException +> from fitbit_client.exceptions import ValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import FoodFormType +> from fitbit_client.resources.constants import FoodPlanIntensity +> from fitbit_client.resources.constants import MealType +> from fitbit_client.resources.constants import NutritionalValue +> from fitbit_client.resources.constants import WaterUnit +> from fitbit_client.utils.date_validation import validate_date_param +> from fitbit_client.utils.helpers import to_camel_case +> from fitbit_client.utils.types import JSONDict +> from fitbit_client.utils.types import JSONList +> from fitbit_client.utils.types import ParamDict + + +> class NutritionResource(BaseResource): +> """Provides access to Fitbit Nutrition API for managing food and water tracking. + +> This resource handles endpoints for logging food intake, creating and managing custom foods +> and meals, tracking water consumption, setting nutritional goals, and retrieving food +> database information. It supports comprehensive tracking of dietary intake and hydration. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/ + +> Required Scopes: +> - nutrition: Required for all endpoints in this resource + +> Note: +> The Nutrition API is one of the most comprehensive Fitbit APIs, with functionality for: +> - Logging foods and water consumption +> - Creating custom foods and meals +> - Managing favorites and frequently used items +> - Searching the Fitbit food database +> - Setting and retrieving nutritional goals +> - Retrieving nutritional unit information + +> Nutrition data is always associated with a specific date, and most logging +> endpoints require a valid foodId from either the Fitbit food database or +> from custom user-created food entries. Meals are collections of food entries +> that can be reused for convenience. + +> All nutritional values are specified in the units set in the user's Fitbit +> account settings (metric or imperial). +> """ + +> def add_favorite_foods(self, food_id: int, user_id: str = "-", debug: bool = False) -> None: +> """ +> Adds a food to the user's list of favorite foods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/add-favorite-foods/ + +> Args: +> food_id: ID of the food to add to favorites (from Fitbit's food database) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.ValidationException: If the food ID is invalid or already a favorite +> fitbit_client.exceptions.NotFoundException: If the food ID doesn't exist + +> Note: +> Favorite foods are displayed prominently when logging meals, making +> it easier to log frequently consumed items. +> """ +> result = self._make_request( +> f"foods/log/favorite/{food_id}.json", user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(None, result) + + # Semantically correct aliases for above. +> add_favorite_food = add_favorite_foods # Arguable +> create_favorite_food = add_favorite_foods # Better + +> def create_food( +> self, +> name: str, +> default_food_measurement_unit_id: int, +> default_serving_size: float, +> calories: int, +> description: str, +> form_type: FoodFormType, +> nutritional_values: Dict[NutritionalValue | str, float | int], +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a new private custom food entry for a user. + +> This endpoint allows users to create their own custom food entries that can be +> reused when logging meals. Custom foods are private to the user's account and +> include detailed nutritional information. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-food/ + +> Args: +> name: Name of the food being created +> default_food_measurement_unit_id: ID from the food units endpoint (get_food_units) +> default_serving_size: Size of default serving with nutritional values +> calories: Number of calories for default serving size +> description: Description of the food +> form_type: Food texture - either FoodFormType.LIQUID or FoodFormType.DRY +> nutritional_values: Dictionary mapping NutritionalValue constants to their amounts +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Created food entry details containing the food object with ID, name, nutritional values, +> and other metadata about the custom food + +> Raises: +> fitbit_client.exceptions.ValidationException: If required parameters are invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The nutritional_values dictionary accepts any nutrition constants defined in +> the NutritionalValue enum, including macronutrients (protein, carbs, fat) and +> micronutrients (vitamins, minerals). Values should be provided in the units +> specified in the user's account settings. + +> Food measurement unit IDs can be retrieved using the get_food_units method. +> Common values include: +> - 226: gram +> - 180: ounce +> - 147: tablespoon +> - 328: milliliter +> """ +> params: ParamDict = { +> "name": name, +> "defaultFoodMeasurementUnitId": default_food_measurement_unit_id, +> "defaultServingSize": default_serving_size, +> "calories": calories, +> "description": description, +> "formType": str(form_type.value), +> } + +> if ( +> nutritional_values +> and NutritionalValue.CALORIES_FROM_FAT in nutritional_values +> and not isinstance(nutritional_values[NutritionalValue.CALORIES_FROM_FAT], int) +> ): +> raise ClientValidationException( +> message="Calories from fat must be an integer", field_name="CALORIES_FROM_FAT" +> ) +> for key, value in nutritional_values.items(): +> if isinstance(key, NutritionalValue): +> if key == NutritionalValue.CALORIES_FROM_FAT: +> params[key.value] = int(value) +> else: +> params[key.value] = float(value) +> else: +> params[str(key)] = float(value) + +> result = self._make_request( +> "foods.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="date") +> def create_food_log( +> self, +> date: str, +> meal_type_id: MealType, +> unit_id: int, +> amount: float, +> food_id: Optional[int] = None, +> food_name: Optional[str] = None, +> favorite: bool = False, +> brand_name: Optional[str] = None, +> calories: Optional[int] = None, +> nutritional_values: Optional[Dict[NutritionalValue, float | int]] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a food log entry for tracking nutrition on a specific day. + +> This endpoint allows recording food consumption either from the Fitbit food database +> or as a custom food entry with nutritional information. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-food-log/ + +> Args: +> date: Log date in YYYY-MM-DD format or 'today' +> meal_type_id: Meal type (BREAKFAST, MORNING_SNACK, LUNCH, etc.) +> unit_id: Unit ID from food units endpoint +> amount: Amount consumed in specified unit (X.XX format) +> food_id: Optional ID of food from Fitbit's database +> food_name: Optional custom food name (required if food_id not provided) +> favorite: Optional flag to add food to favorites (only with food_id) +> brand_name: Optional brand name for custom foods +> calories: Optional calories for custom foods +> nutritional_values: Optional dictionary mapping NutritionalValue constants to their amounts +> (only used with custom foods) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Created food log entry containing the food details, nutritional values, +> and a summary of the day's total nutritional intake + +> Raises: +> fitbit_client.exceptions.ClientValidationException: Must provide either food_id or (food_name and calories) +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> There are two ways to log foods: +> 1. Using food_id from the Fitbit database: Provide food_id, unit_id, and amount +> 2. Custom food entry: Provide food_name, calories, unit_id, and amount + +> The favorite parameter (when set to True) will automatically add the food +> to the user's favorites list when using a food_id. + +> For custom food entries, you can optionally provide: +> - brand_name: To specify the brand of the food +> - nutritional_values: To specify detailed nutritional information + +> The unit_id must match one of the units associated with the food. For +> existing foods, valid units can be found in the food details. For custom +> foods, any valid unit ID from the get_food_units method can be used. + +> The response includes both the created food log entry and a summary of +> the day's nutritional totals after adding this entry. +> """ +> if not food_id and not (food_name and calories): +> raise ClientValidationException( +> "Must provide either food_id or (food_name and calories)" +> ) + +> params: ParamDict = { +> "date": date, +> "mealTypeId": int(meal_type_id.value), +> "unitId": unit_id, +> "amount": amount, +> } + +> if food_id: +> params["foodId"] = food_id +> if favorite: +> params["favorite"] = True +> else: +> params["foodName"] = food_name +> params["calories"] = calories +> if brand_name: +> params["brandName"] = brand_name +> if nutritional_values: + # Convert enum keys to strings and ensure values are floats +> for k, v in nutritional_values.items(): +> key_str = k.value if isinstance(k, NutritionalValue) else str(k) +> params[key_str] = float(v) + +> result = self._make_request( +> "foods/log.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def create_food_goal( +> self, +> calories: Optional[int] = None, +> intensity: Optional[FoodPlanIntensity] = None, +> personalized: Optional[bool] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates or updates a user's daily calorie consumption goal or food plan. + +> This endpoint allows setting either a simple calorie goal or a more complex +> food plan linked to weight management goals. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-food-goal/ + +> Args: +> calories: Optional manual calorie consumption goal +> intensity: Optional food plan intensity (MAINTENANCE, EASIER, MEDIUM, +> KINDAHARD, HARDER) +> personalized: Optional food plan type (true/false) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Updated food goal information containing calorie goals and food plan details +> (if enabled) + +> Raises: +> fitbit_client.exceptions.MissingParameterException: If neither calories nor intensity is provided +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted +> fitbit_client.exceptions.ValidationException: If parameters are invalid + +> Note: +> There are two ways to set nutrition goals: +> 1. Simple calorie goal: Provide only the calories parameter +> 2. Food plan: Provide the intensity parameter, with optional personalized parameter + +> A food plan is linked to weight management and requires an active weight goal +> to be set using the create_weight_goal method in the BodyResource. + +> The food plan intensity levels determine calorie deficit or surplus: +> - MAINTENANCE: Maintain current weight +> - EASIER: Small deficit/surplus for gradual change +> - MEDIUM: Moderate deficit/surplus for steady change +> - KINDAHARD: Large deficit/surplus for faster change +> - HARDER: Maximum recommended deficit/surplus + +> The personalized parameter, when set to true, creates a food plan that +> accounts for the user's activity levels rather than a fixed calorie goal. +> """ +> if not calories and not intensity: +> raise MissingParameterException( +> message="Must provide either calories or intensity", field_name="calories/intensity" +> ) + +> params: ParamDict = {} +> if calories: +> params["calories"] = calories +> if intensity: +> params["intensity"] = str(intensity.value) +> if personalized is not None: +> params["personalized"] = personalized + +> result = self._make_request( +> "foods/log/goal.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def create_meal( +> self, +> name: str, +> description: str, +> foods: List[JSONDict], +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a reusable meal template with the specified foods. + +> This endpoint creates a saved meal template that can be used for easier +> logging of frequently consumed food combinations. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-meal/ + +> Args: +> name: Name of the meal +> description: Short description of the meal +> foods: A list of dicts with the following entries (all required): +> food_id: ID of food to include in meal +> unit_id: ID of units used +> amount: Amount consumed (X.XX format) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Created meal details containing the meal's ID, name, description, and the +> list of foods included in the meal + +> Raises: +> fitbit_client.exceptions.ValidationException: If food objects are incorrectly formatted +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Meals are simply templates that can be reused for easier logging. +> Creating a meal does not automatically log those foods to any date. +> To log these foods on a specific date, you must still use create_food_log +> for each food item in the meal. + +> Meals are always associated with meal type "Anytime" (7) when created, +> but individual foods can be assigned to specific meal types when logged. + +> Each food object in the foods list requires: +> - food_id: Identifier for the food from the Fitbit database or custom foods +> - unit_id: Unit identifier (see get_food_units for available options) +> - amount: Quantity in specified units + +> 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} +> result = self._make_request( +> "meals.json", json=data, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def create_water_goal(self, target: float, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Creates or updates a user's daily water consumption goal. + +> This endpoint sets a target for daily water intake, which is used to track +> hydration progress in the Fitbit app. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-water-goal/ + +> Args: +> target: Target water goal in the unit system matching locale (mL or fl oz) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Updated water goal information containing the target amount and start date + +> Raises: +> fitbit_client.exceptions.ValidationException: If the target value is not positive +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The target value should be specified in the unit system that corresponds +> to the Accept-Language header provided during client initialization +> (fluid ounces for en_US, milliliters for most other locales). + +> Typical daily water intake recommendations range from 1500-3000mL +> (50-100 fl oz) depending on factors like body weight, activity level, +> and climate. + +> Progress toward this goal can be tracked by logging water consumption +> using the create_water_log method. +> """ +> result = self._make_request( +> "foods/log/water/goal.json", +> params={"target": target}, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="date") +> def create_water_log( +> self, +> amount: float, +> date: str, +> unit: Optional[WaterUnit] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a water log entry for tracking hydration on a specific day. + +> This endpoint allows recording water consumption for a given date, which +> contributes to the user's daily hydration tracking. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/create-water-log/ + +> Args: +> amount: Amount of water consumed (X.X format) +> date: Log date in YYYY-MM-DD format or 'today' +> unit: Optional unit (WaterUnit.ML, WaterUnit.FL_OZ, or WaterUnit.CUP) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Created water log entry containing amount, ID, date, and unit information +> (if unit was explicitly provided) + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If amount format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Water logs track hydration over time and contribute to daily water totals. +> Multiple entries can be logged on the same day to track water consumption +> throughout the day. + +> If unit is not specified, the API uses the unit system from the Accept-Language +> header which can be specified when the client is initialized: +> - For en_US locale: fluid ounces (fl oz) +> - For other locales: milliliters (mL) + +> Available water units from the WaterUnit enum: +> - WaterUnit.ML: milliliters (metric) +> - WaterUnit.FL_OZ: fluid ounces (imperial) +> - WaterUnit.CUP: cups (common) + +> Water logs contribute to the daily water total shown in the Fitbit app and +> progress toward any water goal set using create_water_goal. +> """ +> params: ParamDict = {"amount": amount, "date": date} +> if unit: +> params["unit"] = str(unit.value) +> result = self._make_request( +> "foods/log/water.json", params=params, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def delete_custom_food(self, food_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Deletes a custom food permanently from the user's account. + +> This endpoint permanently removes a custom food that was previously created +> by the user. This cannot be used to delete foods from the Fitbit database. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/delete-custom-food/ + +> Args: +> food_id: ID of the custom food to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the food ID doesn't exist +> fitbit_client.exceptions.ValidationException: If attempting to delete a non-custom food +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Only custom foods created by the user (accessLevel: "PRIVATE") can be deleted. +> Foods from the Fitbit database (accessLevel: "PUBLIC") cannot be deleted. + +> Deleting a custom food will also remove it from favorites if it was marked +> as a favorite. Any existing food logs using this food will remain intact, +> but you won't be able to create new logs with this food ID. + +> Custom food IDs can be obtained from the search_foods method or from +> previously created custom foods using create_food. +> """ +> result = self._make_request( +> f"foods/{food_id}.json", user_id=user_id, http_method="DELETE", debug=debug +> ) +> return cast(None, result) + +> def delete_favorite_foods(self, food_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Removes a food from the user's list of favorite foods. + +> This endpoint removes a food from the user's favorites list without +> deleting the food itself from the database. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/delete-favorite-foods/ + +> Args: +> food_id: ID of the food to remove from favorites +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the food ID doesn't exist +> fitbit_client.exceptions.ValidationException: If the food isn't in favorites +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> This endpoint only removes the food from the favorites list. The food itself +> (whether from the Fitbit database or a custom food) remains available for +> logging. This affects which foods appear in the favorites section of the +> Fitbit app when logging meals. + +> Foods can be added to favorites using the add_favorite_foods method or +> by setting the favorite parameter to True when using create_food_log. + +> Food IDs can be obtained from the get_favorite_foods, search_foods, +> get_frequent_foods, or get_recent_foods methods. +> """ +> result = self._make_request( +> f"foods/log/favorite/{food_id}.json", user_id=user_id, http_method="DELETE", debug=debug +> ) +> return cast(None, result) + +> delete_favorite_food = delete_favorite_foods # semantically correct alias + +> def delete_food_log(self, food_log_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Deletes a food log entry permanently. + +> This endpoint permanently removes a specific food log entry from the user's +> food diary for a particular date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/delete-food-log/ + +> Args: +> food_log_id: ID of the food log to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the food log ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Deleting a food log entry removes it from the daily total calculations and +> nutritional summaries for that day. The deletion is permanent and cannot be undone. + +> This operation deletes a specific food log entry (a record of consuming a +> food on a particular date), not the food itself from the database. + +> Food log IDs can be obtained from the get_food_log method, which returns +> all food logs for a specific date. +> """ +> result = self._make_request( +> f"foods/log/{food_log_id}.json", user_id=user_id, http_method="DELETE", debug=debug +> ) +> return cast(None, result) + +> def delete_meal(self, meal_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Deletes a meal template permanently. + +> This endpoint permanently removes a meal template from the user's saved meals. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/delete-meal/ + +> Args: +> meal_id: ID of the meal to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the meal ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Meal templates are simply collections of foods that can be reused for +> easier logging. Deleting a meal template does not affect any food logs +> that may have been previously created using that meal. + +> This operation removes the meal template itself, not the constituent foods +> from the database. + +> Meal IDs can be obtained from the get_meals method, which returns +> all saved meal templates for the user. +> """ +> result = self._make_request( +> f"meals/{meal_id}.json", user_id=user_id, http_method="DELETE", debug=debug +> ) +> return cast(None, result) + +> def delete_water_log(self, water_log_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Deletes a water log entry permanently. + +> This endpoint permanently removes a specific water log entry from the user's +> hydration tracking for a particular date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/delete-water-log/ + +> Args: +> water_log_id: ID of the water log to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the water log ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Deleting a water log entry removes it from the daily hydration total calculations +> for that day. The deletion is permanent and cannot be undone. + +> This affects progress toward any water goal that may be set for the user. + +> Water log IDs can be obtained from the get_water_log method, which returns +> all water logs for a specific date. +> """ +> result = self._make_request( +> f"foods/log/water/{water_log_id}.json", +> user_id=user_id, +> http_method="DELETE", +> debug=debug, +> ) +> return cast(None, result) + +> def get_food(self, food_id: int, debug: bool = False) -> JSONDict: +> """Returns details of a specific food from Fitbit's database or user's private foods. + +> This endpoint retrieves comprehensive information about a food item, including +> nutritional values, serving sizes, and brand information. It can be used to retrieve +> both public foods from Fitbit's database and private custom foods created by the user. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-food/ + +> Args: +> food_id: ID of the food to retrieve +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Food details containing name, brand, calories, available units, and nutritional +> values (for private foods only) + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the food ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If the user lacks permission to access the food +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Nutritional values are only included for PRIVATE (custom) foods. +> For foods from the Fitbit database, only basic information is provided. + +> Food IDs are unique across both the Fitbit database and user's private foods. +> These IDs are used when logging food consumption with the create_food_log method. + +> This endpoint is public and does not require a user_id parameter. +> """ +> result = self._make_request(f"foods/{food_id}.json", requires_user_id=False, debug=debug) +> return cast(JSONDict, result) + +> def get_food_goals(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves the user's daily calorie consumption goal and/or food plan. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-food-goals/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Food goal information containing calorie goals and food plan details (if enabled) + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized for the nutrition scope +> fitbit_client.exceptions.InvalidRequestException: If the user ID is invalid + +> Note: +> Food plan data is only included if the feature is enabled. +> The food plan is tied to the user's weight goals and activity level. +> """ +> result = self._make_request("foods/log/goal.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="date") +> def get_food_log(self, date: str, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a summary of all food log entries for a given day. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-food-log/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Food log data containing an array of logged foods, nutritional goals, and +> a summary of the day's total nutritional values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> The response includes both individual food entries grouped by meal type +> and a daily summary of total nutritional values. Each food entry contains +> both the logged food details and its nutritional contribution to the daily total. +> """ +> result = self._make_request(f"foods/log/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_food_locales(self, debug: bool = False) -> JSONList: +> """Retrieves the list of food locales used for searching and creating foods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-food-locales/ + +> Args: +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of supported locales with country name, language, and locale identifier + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized for the nutrition scope +> fitbit_client.exceptions.ServiceUnavailableException: If the Fitbit service is unavailable + +> Note: +> Locale settings affect food database searches and the units used for +> nutritional values. The selected locale determines which regional +> food database is used for searches and which measurement system +> (metric or imperial) is used for logging values. +> """ +> result = self._make_request("foods/locales.json", requires_user_id=False, debug=debug) +> return cast(JSONList, result) + +> def get_food_units(self, debug: bool = False) -> JSONList: +> """Retrieves list of valid Fitbit food units. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-food-units/ + +> Args: +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of available food measurement units with their IDs, names, and plural forms + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized for the nutrition scope +> fitbit_client.exceptions.ServiceUnavailableException: If the Fitbit service is unavailable + +> Note: +> Unit IDs are used in several nutrition endpoints, including: +> - create_food: For specifying default measurement units for custom foods +> - create_food_log: For specifying the measurement units for logged food quantities +> - update_food_log: For changing the measurement units of existing logs + +> Common unit IDs include: +> - Weight units: 226 (gram), 180 (ounce) +> - Volume units: 328 (milliliter), 218 (fluid ounce), 147 (tablespoon), +> 182 (cup), 189 (pint), 204 (quart) +> - Count units: 221 (serving) +> """ +> result = self._make_request("foods/units.json", requires_user_id=False, debug=debug) +> return cast(JSONList, result) + +> def get_frequent_foods(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Retrieves a list of user's frequently consumed foods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-frequent-foods/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of frequently logged foods with details including food ID, name, brand, +> calories, and available measurement units + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized for the nutrition scope +> fitbit_client.exceptions.InvalidRequestException: If the user ID is invalid + +> Note: +> The frequent foods endpoint returns foods that the user has logged +> multiple times, making it easier to quickly log commonly consumed items. + +> Each food entry contains essential information needed for logging, including: +> - Food identification (foodId, name, brand) +> - Calorie information +> - Available measurement units +> - Default unit for serving size + +> These foods can be efficiently logged using the create_food_log method +> with their foodId values. +> """ +> result = self._make_request("foods/log/frequent.json", user_id=user_id, debug=debug) +> return cast(JSONList, result) + +> def get_recent_foods(self, user_id: str = "-", debug: bool = False) -> JSONList: +> """Retrieves a list of user's recently consumed foods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-recent-foods/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of recently logged foods with details including food ID, log ID, name, +> brand, calories, log date, and available measurement units + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized for the nutrition scope +> fitbit_client.exceptions.InvalidRequestException: If the user ID is invalid + +> Note: +> The recent foods endpoint returns foods that the user has most recently +> logged, sorted with the most recent entries first. Unlike the frequent +> foods endpoint, this includes one-time or infrequently consumed items. + +> Each food entry includes both the food details needed for logging again +> (foodId, name, units) and information about the previous log (logId, logDate). + +> These foods can be efficiently logged again using the create_food_log method +> with their foodId values. +> """ +> result = self._make_request("foods/log/recent.json", user_id=user_id, debug=debug) +> return cast(JSONList, result) + +> def get_favorite_foods(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a list of user's favorite foods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-favorite-foods/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Dictionary containing an array of the user's favorite foods with their +> details including name, brand, calories, and available units + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If not authorized to access this data +> fitbit_client.exceptions.InvalidRequestException: If the request parameters are invalid + +> Note: +> Favorite foods are those explicitly marked as favorites by the user +> using the add_favorite_foods method. These are displayed prominently +> in the Fitbit app and are intended to provide quick access to frequently +> used items. + +> Foods can be added to favorites with the add_favorite_foods method and +> removed with delete_favorite_foods. When logging foods with create_food_log, +> the favorite parameter can be used to automatically add a food to favorites. +> """ +> result = self._make_request("foods/log/favorite.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_meal(self, meal_id: int, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a single meal from user's food log. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-meal/ + +> Args: +> meal_id: ID of the meal to retrieve +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Single meal details containing name, description, ID, and the list of +> foods included in the meal with their amounts and nutritional information + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the meal ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Meals in Fitbit are user-defined collections of foods that can be logged +> together for convenience. All meals are associated with meal type "Anytime" (7), +> regardless of when they're consumed. When logging a meal, the individual +> food items can be assigned to specific meal types (breakfast, lunch, etc.). + +> Meals can be created with the create_meal method, updated with update_meal, +> and deleted with delete_meal. +> """ +> result = self._make_request(f"meals/{meal_id}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_meals(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves list of all user's saved meals. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-meals/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Dictionary containing an array of all user-defined meals with their details +> and constituent foods + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Meals provide a way to save commonly eaten food combinations for +> easy logging. Unlike individual food logs which are associated with +> specific dates, meals are reusable templates that can be applied to +> any date when needed. + +> Each meal has: +> - A unique ID for referencing in other API calls +> - A name and description for identification +> - A list of constituent foods with their amounts and units +> - Calorie information for each food component + +> To log a meal on a specific date, you would need to individually log +> each food in the meal using the create_food_log method. +> """ +> result = self._make_request("meals.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> def get_water_goal(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves user's daily water consumption goal. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-water-goal/ + +> Args: +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Water goal information containing the target amount and when the goal was set + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The target value is expressed in the user's preferred unit system +> (milliliters for metric, fluid ounces for imperial), determined by +> the user's locale settings. The create_water_goal method can be used +> to update this target value. + +> Water consumption is tracked separately from other nutrients but is +> included in the daily summary returned by get_food_log. +> """ +> result = self._make_request("foods/log/water/goal.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="date") +> def get_water_log(self, date: str, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves water log entries for a specific date. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/get-water-log/ + +> Args: +> date: Log date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Water log data containing individual water entries and a summary of +> total water consumption for the day + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Water logs represent individual entries of water consumption throughout +> the day. The summary provides the total amount for the day, while the +> water array contains each individual log entry. + +> Amounts are expressed in the user's preferred unit system (milliliters for +> metric, fluid ounces for imperial), determined by the user's locale settings. + +> Water logs can be created with create_water_log, updated with update_water_log, +> and deleted with delete_water_log. +> """ +> result = self._make_request( +> f"foods/log/water/date/{date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> def search_foods(self, query: str, debug: bool = False) -> JSONDict: +> """Searches Fitbit's food database and user's custom foods. + +> This endpoint allows searching both the Fitbit food database and the user's custom +> foods by name. The search results include basic nutritional information and can +> be used to retrieve food IDs for logging consumption. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/search-foods/ + +> Args: +> query: Search string to match against food names +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Dictionary containing an array of foods matching the search query, with +> details including name, brand, calories, and available units + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Results include both PUBLIC (Fitbit database) and PRIVATE (user-created) foods. +> Search uses the locale specified in the Accept-Language header, which can be +> specified when the client is initialized. + +> This endpoint is public and does not require a user_id parameter, but will +> still return PRIVATE foods for the authenticated user. + +> The search results provide enough information to display basic food details, +> but for comprehensive nutritional information, use the get_food method with +> the returned foodId values. +> """ +> result = self._make_request( +> "foods/search.json", params={"query": query}, requires_user_id=False, debug=debug +> ) +> return cast(JSONDict, result) + +> def update_food_log( +> self, +> food_log_id: int, +> meal_type_id: MealType, +> unit_id: Optional[int] = None, +> amount: Optional[float] = None, +> calories: Optional[int] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Updates an existing food log entry. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/update-food-log/ + +> Args: +> food_log_id: ID of the food log to update +> meal_type_id: Meal type (BREAKFAST, MORNING_SNACK, LUNCH, etc.) +> unit_id: Optional unit ID (required for foods with foodId) +> amount: Optional amount in specified unit (required for foods with foodId) +> calories: Optional calories (only for custom food logs) +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Updated food log entry containing the modified food details with updated +> amount, calories, and nutritional values reflecting the changes + +> Raises: +> fitbit_client.exceptions.MissingParameterException: If neither (unit_id and amount) nor calories are provided +> fitbit_client.exceptions.NotFoundException: If the food log ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Either (unit_id and amount) or calories must be provided: +> - For foods with a valid foodId, provide unit_id and amount to update the serving size +> - For custom food logs (without foodId), provide calories to update the calorie count + +> This method allows changing the meal type (breakfast, lunch, etc.) for a food log +> entry as well as its quantity. The nutritional values are automatically recalculated +> based on the updated amount. + +> Food log IDs can be obtained from the get_food_log method. +> """ +> params: ParamDict = {"mealTypeId": int(meal_type_id.value)} +> if unit_id is not None and amount is not None: +> params["unitId"] = unit_id +> params["amount"] = amount +> elif calories: +> params["calories"] = calories +> else: +> raise MissingParameterException( +> message="Must provide either (unit_id and amount) or calories", +> field_name="unit_id/amount/calories", +> ) + +> result = self._make_request( +> f"foods/log/{food_log_id}.json", +> params=params, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) + +> def update_meal( +> self, +> meal_id: int, +> name: str, +> description: str, +> foods: List[JSONDict], +> debug: bool = False, +> user_id: str = "-", +> ) -> JSONDict: +> """Updates an existing meal. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/update-meal/ + +> Args: +> meal_id: ID of the meal to update +> name: New name of the meal +> description: New short description of the meal +> foods: A list of dicts with the following entries (all required): +> food_id: ID of food to include in meal +> unit_id: ID of units used +> amount: Amount consumed (X.XX format) +> debug: If True, prints a curl command to stdout to help with debugging (default: False) +> user_id: Optional user ID, defaults to current user + +> Returns: +> JSONDict: Updated meal information containing the modified meal details with name, +> description, ID, and the updated list of foods + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the meal ID doesn't exist +> fitbit_client.exceptions.ValidationException: If food objects are incorrectly formatted +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> This method completely replaces the existing meal with the new definition. +> All foods must be specified in the foods list, even those that were previously +> part of the meal and should remain unchanged. Any foods not included in the +> update will be removed from the meal. + +> Each food object in the foods list requires: +> - food_id: Identifier for the food from the Fitbit database or custom foods +> - unit_id: Unit identifier (see get_food_units for available options) +> - amount: Quantity in specified units + +> 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} +> result = self._make_request( +> f"meals/{meal_id}.json", json=data, user_id=user_id, http_method="POST", debug=debug +> ) +> return cast(JSONDict, result) + +> def update_water_log( +> self, +> water_log_id: int, +> amount: float, +> unit: Optional[WaterUnit] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Updates an existing water log entry. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition/update-water-log/ + +> Args: +> water_log_id: ID of the water log to update +> amount: New amount consumed (X.X format) +> unit: Optional unit ('ml', 'fl oz', 'cup') +> user_id: Optional user ID, defaults to current user +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Updated water log entry containing the modified amount, log ID, date, +> and unit information (if unit was explicitly provided) + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the water log ID doesn't exist +> fitbit_client.exceptions.ValidationException: If amount format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> If unit is not specified, the API uses the unit system from the Accept-Language +> header which can be specified when the client is initialized (metric or imperial). + +> Available water units are defined in the WaterUnit enum: +> - WaterUnit.ML: milliliters (metric) +> - WaterUnit.FL_OZ: fluid ounces (imperial) +> - WaterUnit.CUP: cups (common) + +> Water log IDs can be obtained from the get_water_log method. + +> After updating a water log, the daily summary values are automatically recalculated +> to reflect the new hydration total. +> """ +> params: ParamDict = {"amount": amount} +> if unit: +> params["unit"] = str(unit.value) +> result = self._make_request( +> f"foods/log/water/{water_log_id}.json", +> params=params, +> user_id=user_id, +> http_method="POST", +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/nutrition_timeseries.py,cover b/fitbit_client/resources/nutrition_timeseries.py,cover new file mode 100644 index 0000000..3219931 --- /dev/null +++ b/fitbit_client/resources/nutrition_timeseries.py,cover @@ -0,0 +1,139 @@ + # fitbit_client/resources/nutrition_timeseries.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import NutritionResource +> from fitbit_client.resources.constants import Period +> 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 + + +> class NutritionTimeSeriesResource(BaseResource): +> """Provides access to Fitbit Nutrition Time Series API for retrieving historical nutrition data. + +> This resource handles endpoints for retrieving historical food and water consumption data +> over time. It provides daily summaries of calorie and water intake, allowing applications +> to display trends and patterns in nutritional data over various time periods. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition-timeseries/ + +> Required Scopes: +> - nutrition: Required for all endpoints in this resource + +> Note: +> This resource provides access to daily summaries of: +> - Calorie consumption (caloriesIn) +> - Water consumption (water) + +> The data is always returned with date values and can be queried either by +> specifying a base date and period, or by providing explicit start and end dates. + +> All water measurements are returned in the unit system specified by the Accept-Language +> header provided during client initialization (fluid ounces for en_US, milliliters +> for most other locales). +> """ + +> @validate_date_param(field_name="date") +> def get_nutrition_timeseries_by_date( +> self, +> resource: NutritionResource, +> date: str, +> period: Period, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns nutrition data for a period ending on the specified date. + +> This endpoint retrieves daily summaries of calorie intake or water consumption +> for a specified time period ending on the given date. It provides historical +> nutrition data that can be used to analyze trends over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition-timeseries/get-nutrition-timeseries-by-date/ + +> Args: +> resource: Resource to query (NutritionResource.CALORIES_IN or NutritionResource.WATER) +> date: The end date in YYYY-MM-DD format or 'today' +> period: Time period for data (e.g., Period.ONE_DAY, Period.ONE_WEEK, Period.ONE_MONTH) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Dictionary containing daily summary values for calorie intake or water consumption, +> with dates and corresponding values for each day in the period + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Data is returned in chronological order (oldest first). The API only returns +> data from the user's join date or first log entry onward. Days with no logged +> data may be omitted from the response. + +> Water values are returned in the unit system specified by the Accept-Language +> header (fluid ounces for en_US, milliliters for most other locales). +> """ +> result = self._make_request( +> f"foods/log/{resource.value}/date/{date}/{period.value}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=1095) +> def get_nutrition_timeseries_by_date_range( +> self, +> resource: NutritionResource, +> start_date: str, +> end_date: str, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns nutrition data for a specified date range. + +> This endpoint retrieves daily summaries of calorie intake or water consumption +> for a specific date range. It allows for more precise control over the time +> period compared to the period-based endpoint. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/nutrition-timeseries/get-nutrition-timeseries-by-date-range/ + +> Args: +> resource: Resource to query (NutritionResource.CALORIES_IN or NutritionResource.WATER) +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Dictionary containing daily summary values for calorie intake or water consumption, +> with dates and corresponding values for each day in the specified date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date +> or date range exceeds 1095 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Maximum date range is 1095 days (approximately 3 years). + +> Data is returned in chronological order (oldest first). The API only returns +> data from the user's join date or first log entry onward. Days with no logged +> data may be omitted from the response. + +> Water values are returned in the unit system specified by the Accept-Language +> header (fluid ounces for en_US, milliliters for most other locales). + +> This endpoint returns the same data format as get_nutrition_timeseries_by_date, +> but allows for more precise control over the date range. +> """ +> result = self._make_request( +> f"foods/log/{resource.value}/date/{start_date}/{end_date}.json", +> user_id=user_id, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/sleep.py,cover b/fitbit_client/resources/sleep.py,cover new file mode 100644 index 0000000..9da1549 --- /dev/null +++ b/fitbit_client/resources/sleep.py,cover @@ -0,0 +1,371 @@ + # fitbit_client/resources/sleep.py + + # Standard library imports +> from typing import Any +> from typing import Dict +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import ParameterValidationException +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import SortDirection +> 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.pagination_validation import validate_pagination_params +> from fitbit_client.utils.types import JSONDict + + +> class SleepResource(BaseResource): +> """Provides access to Fitbit Sleep API for recording, retrieving and managing sleep data. + +> This resource handles endpoints for creating and retrieving sleep logs, setting sleep goals, +> and accessing detailed sleep statistics and patterns. The API provides information about +> sleep duration, efficiency, and stages (light, deep, REM, awake periods). + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/ + +> Required Scopes: sleep + +> Note: +> All Sleep endpoints use API version 1.2, unlike most other Fitbit API endpoints +> which use version 1. +> """ + +> API_VERSION: str = "1.2" + +> def create_sleep_goals( +> self, min_duration: int, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """ +> Creates or updates a user's sleep duration goal. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/create-sleep-goals/ + +> Args: +> min_duration: Target sleep duration in minutes (must be positive) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Sleep goal details including minimum duration and update timestamp + +> Raises: +> fitbit_client.exceptions.ParameterValidationException: If min_duration is not positive + +> Note: +> Sleep goals help users track and maintain healthy sleep habits. +> The typical recommended sleep duration for adults is 420-480 minutes +> (7-8 hours) per night. +> """ +> if min_duration <= 0: +> raise ParameterValidationException( +> message="min_duration must be positive", field_name="min_duration" +> ) + +> result = self._make_request( +> "sleep/goal.json", +> data={"minDuration": min_duration}, +> user_id=user_id, +> http_method="POST", +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> create_sleep_goal = create_sleep_goals # semantically correct name + +> @validate_date_param(field_name="date") +> def create_sleep_log( +> self, +> date: str, +> duration_millis: int, +> start_time: str, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a manual log entry for a sleep event. + +> This endpoint allows creating manual sleep log entries to track sleep that +> wasn't automatically detected by a device. This is useful for tracking naps +> or sleep periods without wearing a tracker. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/create-sleep-log/ + +> Args: +> date: Log date in YYYY-MM-DD format or 'today' +> duration_millis: Duration in milliseconds (e.g., 28800000 for 8 hours) +> start_time: Sleep start time in HH:mm format (e.g., "23:30") +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Created sleep log entry with sleep metrics and summary information + +> Raises: +> fitbit_client.exceptions.ParameterValidationException: If duration_millis is not positive +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.ValidationException: If time or duration is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> - It is NOT possible to create overlapping log entries +> - The dateOfSleep in the response is the date on which the sleep event ends +> - Manual logs default to "classic" type since they lack the device +> heart rate and movement data needed for "stages" type + +> Duration is provided in milliseconds (1 hour = 3,600,000 ms), while most of the +> response values are in minutes for easier readability. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> if duration_millis <= 0: +> raise ParameterValidationException( +> message="duration_millis must be positive", field_name="duration_millis" +> ) + +> params = {"startTime": start_time, "duration": duration_millis, "date": date} +> result = self._make_request( +> "sleep.json", +> params=params, +> user_id=user_id, +> http_method="POST", +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> def delete_sleep_log(self, log_id: int, user_id: str = "-", debug: bool = False) -> None: +> """Deletes a specific sleep log entry permanently. + +> This endpoint permanently removes a sleep log entry from the user's history. +> This can be used for both automatically tracked and manually entered sleep logs. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/delete-sleep-log/ + +> Args: +> log_id: ID of the sleep log to delete +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> None: This endpoint returns an empty response on success + +> Raises: +> fitbit_client.exceptions.NotFoundException: If the log ID doesn't exist +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Deleting a sleep log entry permanently removes it from the user's history +> and daily summaries. This operation cannot be undone. + +> Sleep log IDs can be obtained from the get_sleep_log_by_date or +> get_sleep_log_list methods. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> result = self._make_request( +> f"sleep/{log_id}.json", +> user_id=user_id, +> http_method="DELETE", +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(None, result) + +> def get_sleep_goals(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Retrieves a user's current sleep goal settings. + +> This endpoint returns the user's target sleep duration goal and related settings. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/get-sleep-goals/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Sleep goal details including target sleep duration (in minutes), +> consistency level, and last update timestamp + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The minDuration value represents the target sleep duration in minutes. +> Typical recommended sleep durations are: +> - 420-480 minutes (7-8 hours) for adults +> - 540-600 minutes (9-10 hours) for teenagers +> - 600-660 minutes (10-11 hours) for children + +> The consistency value indicates the user's adherence to a regular +> sleep schedule over time, with higher values indicating better consistency. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> result = self._make_request( +> "sleep/goal.json", user_id=user_id, api_version=SleepResource.API_VERSION, debug=debug +> ) +> return cast(JSONDict, result) + +> get_sleep_goal = get_sleep_goals # semantically correct name + +> @validate_date_param(field_name="date") +> def get_sleep_log_by_date(self, date: str, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns sleep logs for a specific date. + +> This endpoint retrieves all sleep logs (both automatically tracked and manually entered) +> for a specific date. The response includes detailed information about sleep duration, +> efficiency, and sleep stages if available. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/get-sleep-log-by-date/ + +> Args: +> date: The date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Sleep logs and summary for the specified date, including duration, efficiency and sleep stages + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The data returned includes all sleep periods that ended on the specified date. +> This means a sleep period that began on the previous date but ended on the +> requested date will be included in the response. + +> There are two types of sleep data that may be returned: +> - "classic": Basic sleep with 60-second resolution, showing asleep, restless, and awake states +> - "stages": Advanced sleep with 30-second resolution, showing deep, light, REM, and wake stages + +> Stages data is only available for compatible devices with heart rate tracking. +> Manual entries always use the "classic" type. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> result = self._make_request( +> f"sleep/date/{date}.json", +> user_id=user_id, +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=100) +> def get_sleep_log_by_date_range( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Retrieves sleep logs for a specified date range. + +> This endpoint returns all sleep data (including automatically tracked and manually +> entered sleep logs) for the specified date range, with detailed information about +> sleep duration, efficiency, and sleep stages when available. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/get-sleep-log-by-date-range/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Sleep logs for the specified date range with aggregated sleep statistics + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date or range exceeds 100 days +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> The maximum date range is 100 days. For longer historical periods, you +> will need to make multiple requests with different date ranges. + +> The data returned includes all sleep periods that ended within the +> specified date range. This means a sleep period that began before the +> start_date but ended within the range will be included in the response. + +> As with the single-date endpoint, both "classic" and "stages" sleep data +> may be included depending on device compatibility and how the sleep was logged. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> result = self._make_request( +> f"sleep/date/{start_date}/{end_date}.json", +> user_id=user_id, +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="before_date") +> @validate_date_param(field_name="after_date") +> @validate_pagination_params(max_limit=100) +> def get_sleep_log_list( +> self, +> before_date: Optional[str] = None, +> after_date: Optional[str] = None, +> sort: SortDirection = SortDirection.DESCENDING, +> limit: int = 100, +> offset: int = 0, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Retrieves a paginated list of sleep logs filtered by date. + +> This endpoint returns sleep logs before or after a specified date with +> pagination support. It provides an alternative to date-based queries +> when working with large amounts of sleep data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/sleep/get-sleep-log-list/ + +> Args: +> before_date: Get entries before this date in YYYY-MM-DD format +> after_date: Get entries after this date in YYYY-MM-DD format +> sort: Sort direction (SortDirection.ASCENDING or SortDirection.DESCENDING) +> limit: Number of records to return (max 100) +> offset: Offset for pagination (must be 0) +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Paginated sleep logs with navigation links and sleep entries + +> Raises: +> fitbit_client.exceptions.PaginationError: If parameters are invalid (see Notes) +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Important pagination requirements: +> - Either before_date or after_date MUST be specified (not both) +> - The offset parameter must be 0 (Fitbit API limitation) +> - If before_date is used, sort must be DESCENDING +> - If after_date is used, sort must be ASCENDING + +> To handle pagination properly, use the URLs provided in the "pagination.next" +> and "pagination.previous" fields of the response. This is more reliable than +> manually incrementing the offset. + +> This endpoint returns the same sleep data structure as get_sleep_log_by_date, +> but organized in a paginated format rather than grouped by date. + +> This endpoint uses API version 1.2, unlike most other Fitbit API endpoints. +> """ +> params = {"sort": sort.value, "limit": limit, "offset": offset} +> if before_date: +> params["beforeDate"] = before_date +> if after_date: +> params["afterDate"] = after_date + +> result = self._make_request( +> "sleep/list.json", +> params=params, +> user_id=user_id, +> api_version=SleepResource.API_VERSION, +> debug=debug, +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/spo2.py,cover b/fitbit_client/resources/spo2.py,cover new file mode 100644 index 0000000..9c1b7e0 --- /dev/null +++ b/fitbit_client/resources/spo2.py,cover @@ -0,0 +1,126 @@ + # fitbit_client/resources/spo2.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> 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 JSONList + + +> class SpO2Resource(BaseResource): +> """Provides access to Fitbit SpO2 API for retrieving blood oxygen saturation data. + +> This resource handles endpoints for retrieving blood oxygen saturation (SpO2) measurements +> taken during sleep. SpO2 data provides insights into breathing patterns and potential +> sleep-related breathing disturbances. Normal SpO2 levels during sleep typically range +> between 95-100%. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/spo2/ + +> Required Scopes: +> - oxygen_saturation: Required for all endpoints in this resource + +> Note: +> SpO2 data represents measurements taken during the user's "main sleep" period +> (longest sleep period) and typically spans two dates since measurements are +> taken during overnight sleep. The data is usually associated with the date +> the user wakes up, not the date they went to sleep. + +> SpO2 measurements require compatible Fitbit devices with SpO2 monitoring capability, +> such as certain Sense, Versa, and Charge models with the SpO2 clock face or app installed. + +> The data is calculated on a 5-minute basis during sleep and requires at least 3 hours +> of quality sleep with minimal movement to generate readings. +> """ + +> @validate_date_param(field_name="date") +> def get_spo2_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns SpO2 (blood oxygen saturation) summary data for a specific date. + +> This endpoint provides daily summary statistics for blood oxygen saturation levels +> measured during sleep, including average, minimum, and maximum values. These metrics +> help monitor breathing quality during sleep. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/spo2/get-spo2-summary-by-date/ + +> Args: +> date: Date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: SpO2 summary with average, minimum and maximum blood oxygen percentage values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> SpO2 data requires all of the following conditions: +> - Compatible device with SpO2 monitoring capability +> - SpO2 clock face or app installed and configured +> - At least 3 hours of quality sleep with minimal movement +> - Device sync after waking up +> - Up to 1 hour processing time after sync + +> The date requested typically corresponds to the wake-up date, not the date +> when sleep began. For example, for overnight sleep from June 14 to June 15, +> the data would be associated with June 15. + +> If no SpO2 data is available for the requested date, the API will return an empty +> response: {"dateTime": "2022-06-15"} +> """ +> result = self._make_request(f"spo2/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params() +> def get_spo2_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONList: +> """Returns SpO2 (blood oxygen saturation) summary data for a date range. + +> This endpoint provides daily summary statistics for blood oxygen saturation levels +> over a specified date range. It returns the same data as get_spo2_summary_by_date +> but for multiple days, allowing for trend analysis over time. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/spo2/get-spo2-summary-by-interval/ + +> Args: +> start_date: Start date in YYYY-MM-DD format or "today" +> end_date: End date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONList: List of daily SpO2 summaries for the specified date range + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted + +> Note: +> Unlike many other Fitbit API endpoints, there is no maximum date range limit +> for this endpoint. However, requesting very large date ranges may impact +> performance and is generally not recommended. + +> Days with no available SpO2 data will still be included in the response, but +> without the "value" field: {"dateTime": "2022-06-17"} + +> SpO2 data requirements: +> - Compatible device with SpO2 monitoring capability +> - SpO2 clock face or app installed and configured +> - At least 3 hours of quality sleep with minimal movement +> - Device sync after waking up +> - Up to 1 hour processing time after sync +> """ +> result = self._make_request( +> f"spo2/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONList, result) diff --git a/fitbit_client/resources/subscription.py,cover b/fitbit_client/resources/subscription.py,cover new file mode 100644 index 0000000..9fd0646 --- /dev/null +++ b/fitbit_client/resources/subscription.py,cover @@ -0,0 +1,206 @@ + # fitbit_client/resources/subscription.py + + # Standard library imports +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import SubscriptionCategory +> from fitbit_client.utils.types import JSONDict + + +> class SubscriptionResource(BaseResource): +> """Provides access to Fitbit Subscription API for managing webhook notifications. + +> This resource enables applications to receive real-time notifications when users have +> new data available, eliminating the need to continuously poll the API. Subscriptions +> can be created for specific data categories (activities, body, foods, sleep) or for +> all categories at once. + +> Developer Guide: https://dev.fitbit.com/build/reference/web-api/developer-guide/using-subscriptions/ +> API Reference: https://dev.fitbit.com/build/reference/web-api/subscription/ + +> Required Scopes: +> - For activity subscriptions: activity +> - For body subscriptions: weight +> - For foods subscriptions: nutrition +> - For sleep subscriptions: sleep +> - For all-category subscriptions: all relevant scopes above + +> Implementation Requirements: +> 1. A verification endpoint that responds to GET requests with verification challenges +> 2. A notification endpoint that processes POST requests with updates +> 3. Proper SSL certificates (self-signed certificates are not supported) +> 4. Adherence to rate limits and notification processing timeouts + +> Note: +> Currently only `get_subscription_list` is fully implemented in this library. +> The `create_subscription` and `delete_subscription` methods are defined but raise +> NotImplementedError. Their documentation is provided as a reference for future implementation. + +> Creating both specific and all-category subscriptions will result in duplicate +> notifications for the same data changes, so choose one approach. + +> Subscription notifications are sent as JSON payloads with information about what +> changed, but not the actual data. Your application still needs to make API calls +> to retrieve the updated data. +> """ + +> def create_subscription( +> self, +> subscription_id: str, +> category: Optional[SubscriptionCategory] = None, +> subscriber_id: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Creates a subscription to notify the application when a user has new data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/subscription/create-subscription/ + +> Args: +> subscription_id: Unique ID for this subscription (max 50 chars) +> category: Optional specific data category to subscribe to (e.g., SubscriptionCategory.ACTIVITIES, +> SubscriptionCategory.BODY). If None, subscribes to all categories. +> subscriber_id: Optional subscriber ID from dev.fitbit.com app settings +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Subscription details including collection type, owner and subscription identifiers + +> Raises: +> fitbit_client.exceptions.ValidationException: If subscription_id exceeds 50 characters +> fitbit_client.exceptions.InvalidRequestException: If subscriber_id is invalid +> fitbit_client.exceptions.InsufficientScopeException: If missing required OAuth scopes for the category + +> Note: +> Each subscriber can only have one subscription per user's category. +> If no category is specified, all categories will be subscribed, +> but this requires all relevant OAuth scopes (activity, weight, nutrition, sleep). + +> Subscribers must implement a verification endpoint that can respond to both +> GET (verification) and POST (notification) requests. See the API documentation +> for details on endpoint requirements. +> """ +> raise NotImplementedError + # if len(subscription_id) > 50: + # raise ValidationException( + # message="subscription_id must not exceed 50 characters", + # error_type="validation", + # field_name="subscription_id", + # ) + + # endpoint = ( + # f"{category.value}/apiSubscriptions/{subscription_id}.json" + # if category + # else f"apiSubscriptions/{subscription_id}.json" + # ) + + # headers = {} + # if subscriber_id: + # headers["X-Fitbit-Subscriber-Id"] = subscriber_id + + # return self._make_request( + # endpoint, user_id=user_id, headers=headers, http_method="POST", debug=debug + # ) + +> def delete_subscription( +> self, +> subscription_id: str, +> category: Optional[SubscriptionCategory] = None, +> subscriber_id: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Deletes a subscription for a specific user. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/subscription/delete-subscription/ + +> Args: +> subscription_id: ID of the subscription to delete +> category: Optional specific data category subscription (e.g., SubscriptionCategory.ACTIVITIES, +> SubscriptionCategory.BODY). Must match the category used when creating the subscription. +> subscriber_id: Optional subscriber ID from dev.fitbit.com app settings +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Empty dictionary on successful deletion + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If subscription_id is invalid +> fitbit_client.exceptions.NotFoundException: If subscription doesn't exist +> fitbit_client.exceptions.AuthorizationException: If authentication fails or insufficient permissions + +> Note: +> When deleting a subscription: +> - You must specify the same category that was used when creating the subscription +> - After deletion, your application will no longer receive notifications for that user's data +> - You may want to maintain a local record of active subscriptions to ensure proper cleanup +> """ +> raise NotImplementedError + # endpoint = ( + # f"{category.value}/apiSubscriptions/{subscription_id}.json" + # if category + # else f"apiSubscriptions/{subscription_id}.json" + # ) + + # headers = {} + # if subscriber_id: + # headers["X-Fitbit-Subscriber-Id"] = subscriber_id + + # return self._make_request( + # endpoint, user_id=user_id, headers=headers, http_method="DELETE", debug=debug + # ) + +> def get_subscription_list( +> self, +> category: Optional[SubscriptionCategory] = None, +> subscriber_id: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """Returns a list of subscriptions created by your application for a user. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/subscription/get-subscription-list/ + +> Args: +> category: Optional specific data category to filter by (e.g., SubscriptionCategory.ACTIVITIES, +> SubscriptionCategory.BODY). If omitted, returns all subscriptions. +> subscriber_id: Optional subscriber ID from your app settings on dev.fitbit.com +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: List of active subscriptions with their collection types and identifiers + +> Raises: +> fitbit_client.exceptions.InvalidRequestException: If request parameters are invalid +> fitbit_client.exceptions.AuthorizationException: If authentication fails +> fitbit_client.exceptions.InsufficientScopeException: If missing scopes for requested categories + +> Note: +> For best practice, maintain subscription information in your own database +> and only use this endpoint periodically to ensure data consistency. + +> Each subscription requires the appropriate OAuth scope for that category: +> - activities: activity scope +> - body: weight scope +> - foods: nutrition scope +> - sleep: sleep scope + +> This endpoint returns all subscriptions for a user across all applications +> associated with your subscriber ID. +> """ +> endpoint = ( +> f"{category.value}/apiSubscriptions.json" if category else "apiSubscriptions.json" +> ) + +> headers = {} +> if subscriber_id: +> headers["X-Fitbit-Subscriber-Id"] = subscriber_id + +> result = self._make_request(endpoint, user_id=user_id, headers=headers, debug=debug) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/temperature.py,cover b/fitbit_client/resources/temperature.py,cover new file mode 100644 index 0000000..8c1fc48 --- /dev/null +++ b/fitbit_client/resources/temperature.py,cover @@ -0,0 +1,179 @@ + # fitbit_client/resources/temperature.py + + # Standard library imports +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> 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 + + +> class TemperatureResource(BaseResource): +> """Provides access to Fitbit Temperature API for retrieving temperature measurements. + +> This resource handles endpoints for retrieving two types of temperature data: +> 1. Core temperature: Manually logged by users (e.g., using a thermometer) +> 2. Skin temperature: Automatically measured during sleep by compatible Fitbit devices + +> The API provides methods to retrieve data for single dates or date ranges. +> Temperature data is useful for tracking fever, monitoring menstrual cycles, +> and identifying potential health changes. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/temperature/ + +> Required Scopes: +> - temperature (for all temperature endpoints) + +> Note: +> - Core temperature is in absolute values (e.g., 37.0°C) +> - Skin temperature is reported as variation from baseline (e.g., +0.5°C) +> - Temperature units (Celsius vs Fahrenheit) are determined by the Accept-Language header +> - Not all Fitbit devices support skin temperature measurements +> - Skin temperature measurements require at least 3 hours of quality sleep +> """ + +> @validate_date_param(field_name="date") +> def get_temperature_core_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns core temperature summary data for a single date. + +> This endpoint retrieves temperature data that was manually logged by the user +> on the specified date, typically using a thermometer. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/temperature/get-temperature-core-summary-by-date + +> Args: +> date: Date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Core temperature measurements containing date, time and temperature values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Temperature values are in Celsius or Fahrenheit based on the Accept-Language header +> - Core temperature is the body's internal temperature, not skin temperature +> - Normal core temperature range is typically 36.5°C to 37.5°C (97.7°F to 99.5°F) +> - If no temperature was logged for the date, an empty array is returned +> """ +> result = self._make_request(f"temp/core/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=30) +> def get_temperature_core_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns core temperature data for a specified date range. + +> This endpoint retrieves temperature data that was manually logged by the user +> across the specified date range, typically using a thermometer. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/temperature/get-temperature-core-summary-by-interval + +> Args: +> start_date: Start date in YYYY-MM-DD format or "today" +> end_date: End date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Core temperature measurements for each date in the range with time and temperature values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date or +> date range exceeds 30 days + +> Note: +> - Maximum date range is 30 days +> - Temperature values are in Celsius or Fahrenheit based on the Accept-Language header +> - Days with no logged temperature data will not appear in the results +> - Multiple temperature entries on the same day will all be included +> - The datetime field includes the specific time the measurement was logged +> """ +> result = self._make_request( +> f"temp/core/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="date") +> def get_temperature_skin_summary_by_date( +> self, date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns skin temperature data for a single date. + +> This endpoint retrieves skin temperature data that was automatically measured during +> the user's main sleep period (longest sleep) on the specified date. Skin temperature +> is reported as variation from the user's baseline, not absolute temperature. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/temperature/get-temperature-skin-summary-by-date + +> Args: +> date: Date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Skin temperature measurements containing date and nightly relative values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid + +> Note: +> - Requires compatible Fitbit device with skin temperature measurement capability +> - Values are relative to the user's baseline (e.g., +0.5°C, -0.2°C) +> - Requires at least 3 hours of quality sleep for measurement +> - Data typically spans two dates since it's measured during overnight sleep +> - Takes ~15 minutes after device sync for data to be available +> - The data returned usually reflects a sleep period that began the day before +> - Significant temperature variations may indicate illness, menstrual cycle changes, +> or changes in sleeping environment +> """ +> result = self._make_request(f"temp/skin/date/{date}.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_range_params(max_days=30) +> def get_temperature_skin_summary_by_interval( +> self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False +> ) -> JSONDict: +> """Returns skin temperature data for a specified date range. + +> This endpoint retrieves skin temperature data that was automatically measured during +> the user's main sleep periods across the specified date range. It only returns values +> for dates when the Fitbit device successfully recorded skin temperature data. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/temperature/get-temperature-skin-summary-by-interval + +> Args: +> start_date: Start date in YYYY-MM-DD format or "today" +> end_date: End date in YYYY-MM-DD format or "today" +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Skin temperature measurements for each date in the range with nightly relative values + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If date format is invalid +> fitbit_client.exceptions.InvalidDateRangeException: If start_date is after end_date or +> date range exceeds 30 days + +> Note: +> - Maximum date range is 30 days +> - Values are relative to the user's baseline (e.g., +0.5°C, -0.2°C) +> - Days without valid measurements will not appear in the results +> - Data typically spans two dates since it's measured during overnight sleep +> - The "nightlyRelative" value shows how the measured temperature differs from +> the user's baseline, which is calculated from approximately 30 days of data +> - Tracking trends over time can be more informative than individual readings +> """ +> result = self._make_request( +> f"temp/skin/date/{start_date}/{end_date}.json", user_id=user_id, debug=debug +> ) +> return cast(JSONDict, result) diff --git a/fitbit_client/resources/user.py,cover b/fitbit_client/resources/user.py,cover new file mode 100644 index 0000000..20a4be5 --- /dev/null +++ b/fitbit_client/resources/user.py,cover @@ -0,0 +1,207 @@ + # fitbit_client/resources/user.py + + # Standard library imports +> from typing import Optional +> from typing import cast + + # Local imports +> from fitbit_client.resources.base import BaseResource +> from fitbit_client.resources.constants import ClockTimeFormat +> from fitbit_client.resources.constants import Gender +> from fitbit_client.resources.constants import StartDayOfWeek +> from fitbit_client.utils.date_validation import validate_date_param +> from fitbit_client.utils.types import JSONDict + + +> class UserResource(BaseResource): +> """Provides access to Fitbit User API for managing profile and badge information. + +> This resource handles endpoints for retrieving and updating user profile information, +> including personal details, regional/language settings, measurement preferences, and +> achievement badges. It allows applications to personalize user experiences and display +> user accomplishments. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/user/ + +> Required Scopes: +> - profile: Required for basic profile information access and updates +> - location: Required for accessing and updating regional settings (country, state, city) +> - nutrition: Required for updating food preferences (foods locale, water units) + +> Note: +> The User API contains core user information that affects how data is displayed across +> the entire Fitbit platform. Settings such as measurement units, locale preferences, +> and timezone determine how data is formatted in all other API responses. + +> Access to other users' profile information is subject to their privacy settings, +> particularly the "Personal Info" privacy setting, which must be set to either +> "Friends" or "Public" to allow access. + +> While the profile endpoint requires minimal scope, updating some profile fields +> (like location and food preferences) requires additional scopes. +> """ + +> def get_profile(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """Returns a user's profile information. + +> This endpoint retrieves detailed information about a user's profile, including +> personal details, preferences, and settings. This data can be used to personalize +> the application experience and ensure correct data formatting. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/user/get-profile/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: User profile data containing personal information (name, gender, birth date), +> activity metrics (height, weight, stride length), and preferences (units, +> timezone, locale settings) + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required scope is not granted +> fitbit_client.exceptions.ForbiddenException: If privacy settings restrict access + +> Note: +> Numerical values (height, weight) are returned in units specified by +> the Accept-Language header provided during client initialization. + +> Access to other users' profile data is subject to their privacy settings. +> The "Personal Info" privacy setting must be set to either "Friends" or +> "Public" to allow access to other users' profiles. + +> Some fields may be missing if they haven't been set by the user or if +> privacy settings restrict access to them. +> """ +> result = self._make_request("profile.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) + +> @validate_date_param(field_name="birthday") +> def update_profile( +> self, +> gender: Optional[Gender] = None, +> birthday: Optional[str] = None, +> height: Optional[str] = None, +> about_me: Optional[str] = None, +> full_name: Optional[str] = None, +> country: Optional[str] = None, +> state: Optional[str] = None, +> city: Optional[str] = None, +> clock_time_display_format: Optional[ClockTimeFormat] = None, +> start_day_of_week: Optional[StartDayOfWeek] = None, +> locale: Optional[str] = None, +> locale_lang: Optional[str] = None, +> locale_country: Optional[str] = None, +> timezone: Optional[str] = None, +> foods_locale: Optional[str] = None, +> glucose_unit: Optional[str] = None, +> height_unit: Optional[str] = None, +> water_unit: Optional[str] = None, +> weight_unit: Optional[str] = None, +> stride_length_walking: Optional[str] = None, +> stride_length_running: Optional[str] = None, +> user_id: str = "-", +> debug: bool = False, +> ) -> JSONDict: +> """ +> Updates the user's profile information. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/user/update-profile/ + +> Args: +> gender: User's gender identity (Gender.MALE, Gender.FEMALE, or Gender.NA) +> birthday: Date of birth in YYYY-MM-DD format +> height: Height in X.XX format (units based on Accept-Language header) +> about_me: Text for "About Me" profile field +> full_name: User's full name +> country: Two-character country code (requires location scope) +> state: Two-character state code, valid only for US (requires location scope) +> city: City name (requires location scope) +> clock_time_display_format: 12 or 24 hour format (ClockTimeFormat.TWELVE_HOUR +> or ClockTimeFormat.TWENTY_FOUR_HOUR) +> start_day_of_week: First day of week (StartDayOfWeek.SUNDAY or StartDayOfWeek.MONDAY) +> locale: Website locale (e.g., "en_US", "fr_FR") +> locale_lang: Language code (e.g., "en", used if locale not specified) +> locale_country: Country code (e.g., "US", used if locale not specified) +> timezone: Timezone (e.g., "America/Los_Angeles") +> foods_locale: Food database locale (e.g., "en_US", requires nutrition scope) +> glucose_unit: Glucose unit preference ("en_US" or "METRIC") +> height_unit: Height unit preference ("en_US" or "METRIC") +> water_unit: Water unit preference (requires nutrition scope) +> weight_unit: Weight unit preference ("en_US" or "METRIC") +> stride_length_walking: Walking stride length in X.XX format +> stride_length_running: Running stride length in X.XX format +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Updated user profile data with the same structure as get_profile() + +> Raises: +> fitbit_client.exceptions.InvalidDateException: If birthday format is invalid + +> Note: +> All parameters are optional. Only specified fields will be updated. +> Units for numerical values should match the Accept-Language header. +> Updating location information (country, state, city) requires the 'location' scope. +> Updating food preferences requires the 'nutrition' scope. +> """ +> updates = { +> "gender": gender.value if gender is not None else None, +> "birthday": birthday, +> "height": height, +> "aboutMe": about_me, +> "fullName": full_name, +> "country": country, +> "state": state, +> "city": city, +> "clockTimeDisplayFormat": ( +> clock_time_display_format.value if clock_time_display_format is not None else None +> ), +> "startDayOfWeek": start_day_of_week.value if start_day_of_week is not None else None, +> "locale": locale, +> "localeLang": locale_lang, +> "localeCountry": locale_country, +> "timezone": timezone, +> "foodsLocale": foods_locale, +> "glucoseUnit": glucose_unit, +> "heightUnit": height_unit, +> "waterUnit": water_unit, +> "weightUnit": weight_unit, +> "strideLengthWalking": stride_length_walking, +> "strideLengthRunning": stride_length_running, +> } + +> params = {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 +> ) +> return cast(JSONDict, result) + +> def get_badges(self, user_id: str = "-", debug: bool = False) -> JSONDict: +> """ +> Returns a list of the user's earned achievement badges. + +> API Reference: https://dev.fitbit.com/build/reference/web-api/user/get-badges/ + +> Args: +> user_id: Optional user ID, defaults to current user ("-") +> debug: If True, prints a curl command to stdout to help with debugging (default: False) + +> Returns: +> JSONDict: Contains categorized lists of badges earned by the user (all badges, daily goal badges, +> lifetime achievement badges, and weight goal badges), with detailed information about +> each badge including description, achievement date, and visual elements + +> Raises: +> fitbit_client.exceptions.AuthorizationException: If required profile scope is not granted +> fitbit_client.exceptions.ForbiddenException: If privacy settings restrict access + +> Note: +> Access to badges requires user's "My Achievements" privacy setting +> to allow access. Weight badges are only included if "My Body" privacy +> setting allows access. Some fields may not be present for all badges. +> """ +> result = self._make_request("badges.json", user_id=user_id, debug=debug) +> return cast(JSONDict, result) diff --git a/fitbit_client/utils/__init__.py,cover b/fitbit_client/utils/__init__.py,cover new file mode 100644 index 0000000..e868fb6 --- /dev/null +++ b/fitbit_client/utils/__init__.py,cover @@ -0,0 +1 @@ + # fitbit_client/utils/__init__.py diff --git a/fitbit_client/utils/date_validation.py,cover b/fitbit_client/utils/date_validation.py,cover new file mode 100644 index 0000000..b4b0249 --- /dev/null +++ b/fitbit_client/utils/date_validation.py,cover @@ -0,0 +1,223 @@ + # fitbit_client/utils/date_validation.py + + # Standard library imports +> from datetime import date +> from datetime import datetime +> from functools import wraps +> from inspect import signature +> from typing import Callable +> from typing import Optional +> from typing import ParamSpec +> from typing import TypeVar +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import InvalidDateException +> from fitbit_client.exceptions import InvalidDateRangeException + + # Type variables for decorator typing +> P = ParamSpec("P") +> R = TypeVar("R") + + +> def validate_date_format(date_str: str, field_name: str = "date") -> None: +> """ +> Validates that a date string is either 'today' or YYYY-MM-DD format. + +> This function can be used in two ways: +> 1. Directly, for one-off validations especially with optional parameters: +> ```python +> if date_param: +> validate_date_format(date_param, "date_param") +> ``` +> 2. Via the @validate_date_param decorator for required date parameters: +> ```python +> @validate_date_param() +> def my_method(self, date: str): +> ... +> ``` + +> Args: +> date_str: Date string to validate +> field_name: Name of field for error messages + +> Raises: +> InvalidDateException: If date format is invalid +> """ +> if date_str == "today": +> return + + # Quick format check before attempting to parse +> if not ( +> len(date_str) == 10 +> and date_str[4] == "-" +> and date_str[7] == "-" +> and all(c.isdigit() for c in (date_str[0:4] + date_str[5:7] + date_str[8:10])) +> ): +> raise InvalidDateException(date_str, field_name) + +> try: + # Now validate calendar date +> datetime.strptime(date_str, "%Y-%m-%d") +> except ValueError: +> raise InvalidDateException(date_str, field_name) + + +> def validate_date_range( +> start_date: str, +> end_date: str, +> max_days: Optional[int] = None, +> resource_name: Optional[str] = None, +> start_field: str = "start_date", +> end_field: str = "end_date", +> ) -> None: +> """ +> Validates a date range. + +> This function can be used in two ways: +> 1. Directly, for one-off validations especially with optional parameters: +> ```python +> if start_date and end_date: +> validate_date_range(start_date, end_date, max_days=30) +> ``` +> 2. Via the @validate_date_range_params decorator for required parameters: +> ```python +> @validate_date_range_params(max_days=30) +> def my_method(self, start_date: str, end_date: str): +> ... +> ``` + +> Args: +> start_date: Start date in YYYY-MM-DD format or 'today' +> end_date: End date in YYYY-MM-DD format or 'today' +> max_days: Optional maximum number of days between dates +> resource_name: Optional resource name for error messages +> start_field: Optional field name for start date (default: "start_date") +> end_field: Optional field name for end date (default: "end_date") + +> Raises: +> InvalidDateException: If date format is invalid +> InvalidDateRangeException: If date range is invalid or exceeds max_days +> """ + # Validate individual date formats first +> validate_date_format(start_date, start_field) +> validate_date_format(end_date, end_field) + + # Convert to date objects for comparison +> start = ( +> date.today() if start_date == "today" else datetime.strptime(start_date, "%Y-%m-%d").date() +> ) +> end = date.today() if end_date == "today" else datetime.strptime(end_date, "%Y-%m-%d").date() + + # Check order +> if start > end: +> raise InvalidDateRangeException( +> start_date, end_date, f"Start date {start_date} is after end date {end_date}" +> ) + + # Check max_days if specified and both dates are actual dates (not 'today') +> if max_days and start_date != "today" and end_date != "today": +> date_diff = (end - start).days +> if date_diff > max_days: +> resource_msg = f" for {resource_name}" if resource_name else "" +> raise InvalidDateRangeException( +> start_date, +> end_date, +> f"Date range {start_date} to {end_date} exceeds maximum allowed {max_days} days{resource_msg}", +> max_days, +> resource_name, +> ) + + +> def validate_date_param(field_name: str = "date") -> Callable[[Callable[P, R]], Callable[P, R]]: +> """ +> Decorator to validate a single date parameter. + +> Best used for methods with required date parameters. For optional date parameters, +> consider using validate_date_format() directly instead. + +> Args: +> field_name: Name of field to validate in the decorated function + +> Example: +> ```python +> @validate_date_param() +> def my_method(self, date: str): +> ... + +> @validate_date_param(field_name="log_date") +> def another_method(self, log_date: str): +> ... +> ``` +> """ + +> def decorator(func: Callable[P, R]) -> Callable[P, R]: +> @wraps(func) +> def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: +> sig = signature(func) +> bound_args = sig.bind(*args, **kwargs) +> bound_args.apply_defaults() +> date = bound_args.arguments.get(field_name) + +> if date: +> validate_date_format(date, field_name) +> return func(*args, **kwargs) + +> return cast(Callable[P, R], wrapper) + +> return decorator + + +> def validate_date_range_params( +> start_field: str = "start_date", +> end_field: str = "end_date", +> max_days: Optional[int] = None, +> resource_name: Optional[str] = None, +> ) -> Callable[[Callable[P, R]], Callable[P, R]]: +> """ +> Decorator to validate date range parameters. + +> Best used for methods with required date range parameters. For optional date parameters, +> consider using validate_date_range() directly instead. + +> Args: +> start_field: Name of start date field in decorated function +> end_field: Name of end date field in decorated function +> max_days: Optional maximum allowed days between dates +> resource_name: Optional resource name for error messages + +> Example: +> ```python +> @validate_date_range_params(max_days=30) +> def my_method(self, start_date: str, end_date: str): +> ... + +> @validate_date_range_params(start_field="from_date", end_field="to_date", max_days=100) +> def another_method(self, from_date: str, to_date: str): +> ... +> ``` +> """ + +> def decorator(func: Callable[P, R]) -> Callable[P, R]: +> @wraps(func) +> def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: +> sig = signature(func) +> bound_args = sig.bind(*args, **kwargs) +> bound_args.apply_defaults() +> start_date = bound_args.arguments.get(start_field) +> end_date = bound_args.arguments.get(end_field) + +> if start_date and end_date: +> validate_date_range( +> start_date, +> end_date, +> max_days, +> resource_name, +> start_field=start_field, +> end_field=end_field, +> ) +> return func(*args, **kwargs) + +> return cast(Callable[P, R], wrapper) + +> return decorator diff --git a/fitbit_client/utils/helpers.py,cover b/fitbit_client/utils/helpers.py,cover new file mode 100644 index 0000000..a8f8ecc --- /dev/null +++ b/fitbit_client/utils/helpers.py,cover @@ -0,0 +1,74 @@ + # fitbit_client/utils/helpers.py + +> """ +> Utility functions we often need when working with the Fitbit API. +> """ + + # Standard library imports +> from datetime import date +> from datetime import timedelta +> from json import dumps +> from sys import stdout +> from typing import Iterator +> from typing import TextIO + + # Local imports +> from fitbit_client.utils.types import JSONType + + +> def to_camel_case(snake_str: str, cap_first: bool = False) -> str: +> """ +> Convert a snake_case string to cameCase or CamelCase. + +> Args: +> snake_str: a snake_case string +> cap_first: if True, returns CamelCase, otherwise camelCase (default is False) +> """ +> if not snake_str: # handle empty string case +> return "" + +> camel_string = "".join(l.capitalize() for l in snake_str.lower().split("_")) +> if cap_first: +> return camel_string +> else: +> return snake_str[0].lower() + camel_string[1:] + + +> def print_json(obj: JSONType, f: TextIO = stdout) -> None: +> """ +> Pretty print JSON-serializable objects. + +> Args: +> obj: Any JSON serializable object +> f: A file-like object to which the object should be serialized. Default: stdout +> """ +> print(dumps(obj, ensure_ascii=False, indent=2), file=f, flush=True) + + +> def date_range(start_date: str, end_date: str) -> Iterator[str]: +> """ +> Generates dates between start_date and end_date inclusive, in ISO +> formatted (YYYY-MM-DD) strings. If the end date is before the start +> date, iterates in reverse. This is the date format the Fitbit API always +> wants, and it's useful in writing quick scripts for pulling down multiple +> days of data when another method is not supported. + +> Args: +> start_date: Starting date in YYYY-MM-DD format +> end_date: Ending date in YYYY-MM-DD format + +> Yields: +> str: Each date in the range in YYYY-MM-DD format +> """ +> end = date.fromisoformat(end_date) +> start = date.fromisoformat(start_date) +> if end < start: +> while start >= end: +> yield start.isoformat() +> start -= timedelta(days=1) +> elif end > start: +> while start <= end: +> yield start.isoformat() +> start += timedelta(days=1) +> else: # start == end +> yield start.isoformat() diff --git a/fitbit_client/utils/pagination_validation.py,cover b/fitbit_client/utils/pagination_validation.py,cover new file mode 100644 index 0000000..132b80c --- /dev/null +++ b/fitbit_client/utils/pagination_validation.py,cover @@ -0,0 +1,126 @@ + # fitbit_client/utils/pagination_validation.py + + # Standard library imports +> from functools import wraps +> from inspect import signature +> from typing import Callable +> from typing import Optional +> from typing import ParamSpec +> from typing import TypeVar +> from typing import cast + + # Local imports +> from fitbit_client.exceptions import PaginationException +> from fitbit_client.resources.constants import SortDirection + + # Type variables for decorator typing +> P = ParamSpec("P") +> R = TypeVar("R") + + +> def validate_pagination_params( +> before_field: str = "before_date", +> after_field: str = "after_date", +> sort_field: str = "sort", +> limit_field: str = "limit", +> offset_field: str = "offset", +> max_limit: int = 100, +> ) -> Callable[[Callable[P, R]], Callable[P, R]]: +> """ +> Decorator to validate pagination parameters commonly used in list endpoints. + +> Validates: +> - Either before_date or after_date must be specified +> - Sort direction must match the date parameter used (ascending with after_date, descending with before_date) +> - Offset must be 0 for endpoints that don't support true pagination +> - Limit must not exceed the specified maximum + +> Args: +> before_field: Name of the before date parameter (default: "before_date") +> after_field: Name of the after date parameter (default: "after_date") +> sort_field: Name of the sort direction parameter (default: "sort") +> limit_field: Name of the limit parameter (default: "limit") +> offset_field: Name of the offset parameter (default: "offset") +> max_limit: Maximum allowed value for limit parameter (default: 100) + +> Returns: +> Decorated function that validates pagination parameters + +> Example: +> ```python +> @validate_pagination_params(max_limit=10) +> def get_log_list( +> self, +> before_date: Optional[str] = None, +> after_date: Optional[str] = None, +> sort: SortDirection = SortDirection.DESCENDING, +> limit: int = 10, +> offset: int = 0, +> ): +> ... +> ``` + +> Raises: +> PaginatonError: If neither before_date nor after_date is specified +> PaginatonError: If offset is not 0 +> PaginatonError: If limit exceeds 10 +> PaginatonError: If sort is not 'asc' or 'desc' +> PaginatonError: If sort direction doesn't match date parameter +> InvalidDateException: If date format is invalid +> """ + +> def decorator(func: Callable[P, R]) -> Callable[P, R]: +> @wraps(func) +> def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + # Bind arguments to get access to parameter values +> sig = signature(func) +> bound_args = sig.bind(*args, **kwargs) +> bound_args.apply_defaults() + + # Extract parameters +> before_date = bound_args.arguments.get(before_field) +> after_date = bound_args.arguments.get(after_field) +> sort = bound_args.arguments.get(sort_field) +> limit = bound_args.arguments.get(limit_field) +> offset = bound_args.arguments.get(offset_field) + + # Validate offset +> if offset != 0: +> raise PaginationException( +> message="Only offset=0 is supported. Use pagination links in response for navigation.", +> field_name=offset_field, +> ) + + # Validate limit - add null check to fix mypy error +> if limit is not None and limit > max_limit: +> raise PaginationException( +> message=f"Maximum limit is {max_limit}", field_name=limit_field +> ) + + # Validate sort value +> if not isinstance(sort, SortDirection): +> raise PaginationException( +> message="Sort must be a SortDirection enum value", field_name=sort_field +> ) + + # Validate date parameters are present +> if not before_date and not after_date: +> raise PaginationException( +> message=f"Either {before_field} or {after_field} must be specified" +> ) + + # Validate sort direction matches date parameter +> if before_date and sort != SortDirection.DESCENDING: +> raise PaginationException( +> message=f"Must use sort=DESCENDING with {before_field}", field_name=sort_field +> ) +> if after_date and sort != SortDirection.ASCENDING: +> raise PaginationException( +> message=f"Must use sort=ASCENDING with {after_field}", field_name=sort_field +> ) + +> return func(*args, **kwargs) + +> return cast(Callable[P, R], wrapper) + +> return decorator diff --git a/fitbit_client/utils/types.py,cover b/fitbit_client/utils/types.py,cover new file mode 100644 index 0000000..ec84216 --- /dev/null +++ b/fitbit_client/utils/types.py,cover @@ -0,0 +1,29 @@ + # fitbit_client/utils/types.py + + # Standard library imports +> from typing import Dict +> from typing import List +> from typing import TypedDict +> from typing import Union + + # Define a generic type for JSON data +> JSONPrimitive = Union[str, int, float, bool, None] +> JSONType = Union[Dict[str, "JSONType"], List["JSONType"], JSONPrimitive] + + # "Wrapper" types that at least give a hint at the outermost structure +> JSONDict = Dict[str, JSONType] +> JSONList = List[JSONType] + + # Types for API parameter values +> ParamValue = Union[str, int, float, bool, None] +> ParamDict = Dict[str, ParamValue] + + + # Type definitions for token structure +> class TokenDict(TypedDict, total=False): +> access_token: str +> refresh_token: str +> token_type: str +> expires_in: int +> expires_at: float +> scope: List[str] diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py index 9998bb6..86cec97 100644 --- a/tests/auth/test_oauth.py +++ b/tests/auth/test_oauth.py @@ -373,6 +373,38 @@ def test_fetch_token_catches_oauth_errors(self, oauth): mock_logger.error.assert_called_once() log_message = mock_logger.error.call_args[0][0] assert "OAuthException" in log_message + assert "during token fetch" in log_message + + def test_fetch_token_no_matching_error_type(self, oauth): + """Test fetch_token when no error type matches in ERROR_TYPE_EXCEPTIONS""" + # Local imports + from fitbit_client.exceptions import OAuthException + + # Create a mock response with an error message that doesn't match any error types + original_error = Exception("Some completely unknown error type") + mock_session = Mock() + mock_session.fetch_token.side_effect = original_error + oauth.session = mock_session + + # Setup logger mock to capture log message + mock_logger = Mock() + oauth.logger = mock_logger + + # The method should fall through to the default OAuthException + with raises(OAuthException) as exc_info: + oauth.fetch_token("callback_url") + + # Verify the wrapped exception has correct attributes + assert str(original_error) in str(exc_info.value) + assert exc_info.value.status_code == 400 + assert exc_info.value.error_type == "oauth" + + # Verify the error was logged correctly with the specific message format + mock_logger.error.assert_called_once() + log_message = mock_logger.error.call_args[0][0] + assert "OAuthException during token fetch" in log_message + assert original_error.__class__.__name__ in log_message + assert str(original_error) in log_message # Token Refresh Tests def test_refresh_token_returns_typed_dict(self, oauth): @@ -532,6 +564,15 @@ def test_load_token_general_exception(self, oauth): token = oauth._load_token() assert token is None + def test_load_token_oserror_exception(self, oauth): + """Test handling of OSError exception during token loading""" + with ( + patch("os.path.exists", return_value=True), + patch("builtins.open", side_effect=OSError("permission denied")), + ): + token = oauth._load_token() + assert token is None + def test_load_token_expired_with_refresh(self, oauth): """Test loading expired token with valid refresh token""" expired_token = {