import fitbit from fitbit import exceptions from datetime import datetime, timedelta from typing import List, Dict, Any, Optional import logging import time from ..utils.helpers import setup_logger logger = setup_logger(__name__) class FitbitClient: def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None, redirect_uri: str = None): self.client_id = client_id self.client_secret = client_secret self.access_token = access_token self.refresh_token = refresh_token self.redirect_uri = redirect_uri self.fitbit = None # Initialize Fitbit class if we have enough info, or just for auth flow # The example initializes it immediately if client_id and client_secret: self.fitbit = fitbit.Fitbit( client_id, client_secret, access_token=access_token, refresh_token=refresh_token, redirect_uri=redirect_uri, timeout=10 ) def get_authorization_url(self, redirect_uri: str = None) -> str: """Generate authorization URL for Fitbit OAuth flow.""" # Update internal redirect_uri if provided if redirect_uri: self.redirect_uri = redirect_uri # Re-init or update client? Fitbit class uses it in init. # It seems simpler to recreate the instance or update the client manually if supported. # But based on the example, we can just init with it. self.fitbit = fitbit.Fitbit( self.client_id, self.client_secret, redirect_uri=redirect_uri, timeout=10 ) # The example calls self.fitbit.client.authorize_token_url() # Note: The fitbit library might default to certain scopes or we need to pass them? # The example used: url, _ = self.fitbit.client.authorize_token_url() # But we need scopes. scope = ['weight', 'nutrition', 'activity', 'sleep', 'heartrate', 'profile'] # The underlying client is oauthlib.oauth2.WebApplicationClient usually auth_url, _ = self.fitbit.client.authorize_token_url(scope=scope) logger.info(f"Generated Fitbit authorization URL: {auth_url}") return auth_url def exchange_code_for_token(self, code: str, redirect_uri: str = None) -> Dict[str, Any]: """Exchange authorization code for access and refresh tokens.""" # If redirect_uri is provided here, ensure we are using a client configured with it if redirect_uri and redirect_uri != self.redirect_uri: self.fitbit = fitbit.Fitbit( self.client_id, self.client_secret, redirect_uri=redirect_uri, timeout=10 ) logger.info(f"Exchanging authorization code for tokens") # The example: self.fitbit.client.fetch_access_token(code) # It updates the internal token automatically. token = self.fitbit.client.fetch_access_token(code) return token def get_weight_logs(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]: """Fetch weight logs from Fitbit API.""" if not self.fitbit: raise Exception("Fitbit client not authenticated") if not end_date: end_date = datetime.now().strftime('%Y-%m-%d') try: print(f"Making request to Fitbit API: get_bodyweight(base_date={start_date}, end_date={end_date})", flush=True) # get_bodyweight returns {'weight': [...]} weight_logs = self.fitbit.get_bodyweight( base_date=start_date, end_date=end_date ) logs = weight_logs.get('weight', []) print(f"Fitbit Response: Success. Fetched {len(logs)} weight entries.", flush=True) return logs except exceptions.HTTPTooManyRequests as e: retry_after = e.retry_after_secs if hasattr(e, 'retry_after_secs') else 'unknown' print(f"Fitbit API Rate Limit Hit! Retry-After: {retry_after} seconds.", flush=True) if not retry_after or retry_after == 'unknown': if hasattr(e, 'response') and e.response is not None: retry_after = e.response.headers.get('Retry-After', 'unknown') print(f"Rate Limited. Recommended wait: {retry_after}", flush=True) raise e except KeyError as e: # Handle specific KeyError from fitbit library when parsing 429 response if str(e).strip("'\"") == 'retry-after': print("Fitbit Library KeyError 'retry-after' detected. This is a Rate Limit event.", flush=True) # Raise as TooManyRequests manually # We don't have the response object easily here unless we dig into stack, # so we assume a safe default wait time. print("Rate Limited (inferred). Recommended wait: 60 seconds (default).", flush=True) # Create a mock exception or just raise HTTPTooManyRequests with limited info # The library's exception requires a response object in init usually, but let's try to simulate or just raise generic # Actually, better to raise the library's exception if possible, or our own wrappers. # Let's just re-raise as the libraries exception but monkey-patch the retry_after_secs if possible? # No, just raise it and let the caller handle it. # Since we can't easily construct the proper exception without a response object, # we'll raise a new HTTPTooManyRequests with a valid retry_after_secs if we can, or just let sync.py handle generic error? # Sync.py catches Exception. # Better: Log clearly and raise a clean error that implies rate limit so user sees it. # AND if we want to auto-retry, we need to signal that. # Let's assume 60s wait. # We can construct a dummy object to hold the retry value if needed, # but for now let's just print and raise. # To make sync.py sleep, we can modify sync.py. # Or here, we can sleep? No, sync.py controls the loop. pass # Fall through to generic Exception handler which prints it? # No, we want to customize the message. raise Exception("Fitbit Rate Limit Hit (Library Error). Retry-After: 60s") from e raise e except Exception as e: print(f"Error fetching weight logs from Fitbit: {str(e)}", flush=True) if hasattr(e, 'response') and e.response is not None: print(f"Fitbit API Error Response: {e.response.text}", flush=True) print(f"Fitbit API Error Headers: {e.response.headers}", flush=True) raise e def refresh_access_token(self) -> Dict[str, str]: """Refresh the Fitbit access token.""" # Implementation for token refresh logger.info("Refreshing Fitbit access token") # Return mock response for now return { "access_token": "new_mock_access_token", "expires_at": (datetime.now() + timedelta(hours=1)).isoformat() }