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/__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 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/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 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/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 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/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 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/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 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/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 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/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 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/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 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/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 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/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_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..86cec97 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_unhandled_error_logging(self, oauth):
- """Test unhandled error logging in fetch_token method"""
+ 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
+
+ # 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,51 @@ 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
+ 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):
@@ -398,38 +444,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"""
@@ -498,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 = {
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