diff --git a/.gitignore b/.gitignore index 7a24f86..5d7bda1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ _scripts _cov_html secrets.json tokens.json +TODO.mdø + diff --git a/README.md b/README.md index 8bee415..2c04afa 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,10 @@ try: client.authenticate() # Make a request (e.g., get user profile) + # You can access resources directly: profile = client.user.get_profile() + # Or use method aliases for shorter syntax: + profile = client.get_profile() print(dumps(profile, indent=2)) except Exception as e: @@ -59,6 +62,29 @@ The response will always be the body of the API response, and is almost always a `Dict`, `List` or `None`. `nutrition.get_activity_tcx` is the exception. It returns XML (as a `str`). +## Method Aliases + +All resource methods are available directly from the client instance. This means +you can use: + +```python +# Short form with method aliases +client.get_profile() +client.get_daily_activity_summary(date="2025-03-06") +client.get_sleep_log_by_date(date="2025-03-06") +``` + +Instead of the longer form: + +```python +# Standard resource access +client.user.get_profile() +client.activity.get_daily_activity_summary(date="2025-03-06") +client.sleep.get_sleep_log_by_date(date="2025-03-06") +``` + +Both approaches are equivalent, but aliases provide a more concise syntax. + ## Authentication Uses a local callback server to automatically handle the OAuth2 flow: @@ -69,6 +95,8 @@ client = FitbitClient( client_secret="YOUR_CLIENT_SECRET", redirect_uri="YOUR_REGISTERED_REDIRECT_URI", token_cache_path="/tmp/fb_tokens.json" # Optional: saves tokens between sessions + redirect_uri="YOUR_REGISTERED_REDIRECT_URI", + token_cache_path="/tmp/fb_tokens.json" # Optional: saves tokens between sessions ) # Will open browser and handle callback automatically @@ -79,15 +107,45 @@ The `token_cache_path` parameter allows you to persist authentication tokens between sessions. If provided, the client will: 1. Load existing tokens from this file if available (avoiding re-authentication) + 2. Save new or refreshed tokens to this file automatically -3. Handle token refresh when expired tokens are detected + +3. Handle token refresh when expired tokens are detected The `token_cache_path` + parameter allows you to persist authentication tokens between sessions. If + provided, the client will: + +4. Load existing tokens from this file if available (avoiding re-authentication) + +5. Save new or refreshed tokens to this file automatically + +6. Handle token refresh when expired tokens are detected ## Setting Up Your Fitbit App 1. Go to dev.fitbit.com and create a new application 2. Set OAuth 2.0 Application Type to "Personal" 3. Set Callback URL to "https://localhost:8080" (or your preferred local URL) -4. Copy your Client ID and Client Secret +4. Set Callback URL to "https://localhost:8080" (or your preferred local URL) +5. Copy your Client ID and Client Secret + +## Additional Documentation + +### For API Library Users + +- [LOGGING.md](docs/LOGGING.md): Understanding the dual-logger system +- [TYPES.md](docs/TYPES.md): JSON type system and method return types +- [NAMING.md](docs/NAMING.md): API method naming conventions +- [VALIDATIONS.md](docs/VALIDATIONS.md): Input parameter validation +- [ERROR_HANDLING.md](docs/ERROR_HANDLING.md): Exception hierarchy and handling + +It's also worth reviewing +[Fitbit's Best Practices](https://dev.fitbit.com/build/reference/web-api/developer-guide/best-practices/) +for API usage. + +### Project Best Practices + +- [DEVELOPMENT.md](docs/DEVELOPMENT.md): Development environment and guidelines +- [STYLE.md](docs/STYLE.md): Code style and formatting standards ## Additional Documentation @@ -120,6 +178,13 @@ The methods are implemented in comments and should work, but I have not had a chance to verify them since this requires a publicly accessible server to receive webhook notifications. +If you're using this library with subscriptions and would like to help test and +implement this functionality, please open an issue or pull request! +[webhook subscriptions](https://dev.fitbit.com/build/reference/web-api/developer-guide/using-subscriptions/). +The methods are implemented in comments and should work, but I have not had a +chance to verify them since this requires a publicly accessible server to +receive webhook notifications. + If you're using this library with subscriptions and would like to help test and implement this functionality, please open an issue or pull request! diff --git a/TODO.md b/TODO.md index 8295439..0cff1f3 100644 --- a/TODO.md +++ b/TODO.md @@ -2,27 +2,6 @@ ## TODOs: -- Create and Test that all methods have an alias in `Client` and that the - signatures match - -- Improve README for end users: - - - Add more common use cases examples beyond basic profile retrieval - - Explain token persistence between sessions (DONE) - - Provide overview of available resources/endpoints - - Verify correct callback URI guidance (check if "https://localhost:8080" is - actually the correct/optimal value to recommend) - -- ✅ Review and improve all documentation files in docs/ from an end-user - perspective - - - ✅ Split NAMING_AND_TYPING.md into TYPES.md and NAMING.md - - ✅ Split VALIDATIONS_AND_EXCEPTIONS.md into VALIDATIONS.md and - ERROR_HANDLING.md - - ✅ Update cross-references between documentation files - - ✅ Fix intraday data support information in DEVELOPMENT.md - - ✅ Add information about disabling data logging to LOGGING.md - - PyPi deployment - For all `create_...`methods, add the ID from the response to logs and maybe @@ -38,6 +17,11 @@ - Rename to `_base`? Files it first, makes it clearer that everything in it is private +- client.py: + + - Creat and Test that all methods have an alias in `Client` and that the + signatures match + - CI: * Read and implement: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6d01e06..e7e93a4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -16,6 +16,9 @@ - [Logging System](#logging-system) - [Application Logger](#application-logger) - [Data Logger](#data-logger) +- [API Design](#api-design) + - [Resource-Based API](#resource-based-api) + - [Method Aliases](#method-aliases) - [Testing](#testing) - [Test Organization](#test-organization) - [Standard Test Fixtures](#standard-test-fixtures) @@ -235,6 +238,63 @@ Data log entries contain: This logging system provides both operational visibility through the application logger and structured data capture through the data logger. +## API Design + +The client implements a dual-level API design pattern that balances both +organization and ease-of-use. + +### Resource-Based API + +The primary API structure is resource-based, organizing related endpoints into +dedicated resource classes: + +- `client.user` - User profile and badges endpoints +- `client.activity` - Activity tracking, goals, and summaries +- `client.sleep` - Sleep logs and goals +- etc. + +This organization provides a clean separation of concerns and makes the code +more maintainable by grouping related functionality. + +### Method Aliases + +To improve developer experience, all resource methods are also available +directly from the client instance through aliases. This means developers can +choose between two equivalent approaches: + +```python +# Standard resource-based access +client.user.get_profile() +client.activity.get_daily_activity_summary(date="2025-03-06") + +# Direct access via method aliases +client.get_profile() +client.get_daily_activity_summary(date="2025-03-06") +``` + +#### Rationale for Method Aliases + +Method aliases were implemented for several important reasons: + +1. **Reduced Verbosity**: Typing `client.resource_name.method_name(...)` with + many parameters can be tedious, especially when used frequently. + +2. **Flatter API Surface**: Many modern APIs prefer a flatter design that avoids + deep nesting, making the API more straightforward to use. + +3. **Method Name Uniqueness**: All resource methods in the Fitbit API have + unique names (e.g., there's only one `get_profile()` method), making it safe + to expose these methods directly on the client. + +4. **Preserve Both Options**: By maintaining both the resource-based access and + direct aliases, developers can choose the approach that best fits their needs + \- organization or conciseness. + +All method aliases are set up in the `_setup_method_aliases()` method in the +`FitbitClient` class, which is called during initialization. Each alias is a +direct reference to the corresponding resource method, ensuring consistent +behavior regardless of how the method is accessed. + ## Testing The project uses pytest for testing and follows a consistent testing approach diff --git a/fitbit_client/client.py b/fitbit_client/client.py index 8f9b672..fec2628 100644 --- a/fitbit_client/client.py +++ b/fitbit_client/client.py @@ -113,7 +113,8 @@ def __init__( # isort: on self.logger.debug("Fitbit client initialized successfully") - # API aliases will be re-implemented after resource methods have been refactored. + # Set up method aliases + self._setup_method_aliases() def authenticate(self, force_new: bool = False) -> bool: """ @@ -145,3 +146,200 @@ def authenticate(self, force_new: bool = False) -> bool: except SystemException as e: self.logger.error(f"System error during authentication: {str(e)}") raise + + def _setup_method_aliases(self) -> None: + """Set up direct access to resource methods as client attributes for convenience.""" + self.logger.debug("Setting up method aliases") + + # Active Zone Minutes + self.get_azm_timeseries_by_date = self.active_zone_minutes.get_azm_timeseries_by_date + self.get_azm_timeseries_by_interval = ( + self.active_zone_minutes.get_azm_timeseries_by_interval + ) + + # Activity Timeseries + self.get_activity_timeseries_by_date = ( + self.activity_timeseries.get_activity_timeseries_by_date + ) + self.get_activity_timeseries_by_date_range = ( + self.activity_timeseries.get_activity_timeseries_by_date_range + ) + + # Activity + self.create_activity_goals = self.activity.create_activity_goals + self.create_activity_goal = self.activity.create_activity_goal + self.create_activity_log = self.activity.create_activity_log + self.get_activity_log_list = self.activity.get_activity_log_list + self.create_favorite_activity = self.activity.create_favorite_activity + self.delete_activity_log = self.activity.delete_activity_log + self.delete_favorite_activity = self.activity.delete_favorite_activity + self.get_activity_goals = self.activity.get_activity_goals + self.get_daily_activity_summary = self.activity.get_daily_activity_summary + self.get_activity_type = self.activity.get_activity_type + self.get_all_activity_types = self.activity.get_all_activity_types + self.get_favorite_activities = self.activity.get_favorite_activities + self.get_frequent_activities = self.activity.get_frequent_activities + self.get_recent_activity_types = self.activity.get_recent_activity_types + self.get_lifetime_stats = self.activity.get_lifetime_stats + self.get_activity_tcx = self.activity.get_activity_tcx + + # Body Timeseries + self.get_body_timeseries_by_date = self.body_timeseries.get_body_timeseries_by_date + self.get_body_timeseries_by_date_range = ( + self.body_timeseries.get_body_timeseries_by_date_range + ) + self.get_bodyfat_timeseries_by_date = self.body_timeseries.get_bodyfat_timeseries_by_date + self.get_bodyfat_timeseries_by_date_range = ( + self.body_timeseries.get_bodyfat_timeseries_by_date_range + ) + self.get_weight_timeseries_by_date = self.body_timeseries.get_weight_timeseries_by_date + self.get_weight_timeseries_by_date_range = ( + self.body_timeseries.get_weight_timeseries_by_date_range + ) + + # Body + self.create_bodyfat_goal = self.body.create_bodyfat_goal + self.create_bodyfat_log = self.body.create_bodyfat_log + self.create_weight_goal = self.body.create_weight_goal + self.create_weight_log = self.body.create_weight_log + self.delete_bodyfat_log = self.body.delete_bodyfat_log + self.delete_weight_log = self.body.delete_weight_log + self.get_body_goals = self.body.get_body_goals + self.get_bodyfat_log = self.body.get_bodyfat_log + self.get_weight_logs = self.body.get_weight_logs + + # Breathing Rate + self.get_breathing_rate_summary_by_date = ( + self.breathing_rate.get_breathing_rate_summary_by_date + ) + self.get_breathing_rate_summary_by_interval = ( + self.breathing_rate.get_breathing_rate_summary_by_interval + ) + + # Cardio Fitness Score + self.get_vo2_max_summary_by_date = self.cardio_fitness_score.get_vo2_max_summary_by_date + self.get_vo2_max_summary_by_interval = ( + self.cardio_fitness_score.get_vo2_max_summary_by_interval + ) + + # Device + self.get_devices = self.device.get_devices + + # Electrocardiogram + self.get_ecg_log_list = self.electrocardiogram.get_ecg_log_list + + # Friends + self.get_friends = self.friends.get_friends + self.get_friends_leaderboard = self.friends.get_friends_leaderboard + + # Heartrate Timeseries + self.get_heartrate_timeseries_by_date = ( + self.heartrate_timeseries.get_heartrate_timeseries_by_date + ) + self.get_heartrate_timeseries_by_date_range = ( + self.heartrate_timeseries.get_heartrate_timeseries_by_date_range + ) + + # Heartrate Variability + self.get_hrv_summary_by_date = self.heartrate_variability.get_hrv_summary_by_date + self.get_hrv_summary_by_interval = self.heartrate_variability.get_hrv_summary_by_interval + + # Intraday + self.get_azm_intraday_by_date = self.intraday.get_azm_intraday_by_date + self.get_azm_intraday_by_interval = self.intraday.get_azm_intraday_by_interval + self.get_activity_intraday_by_date = self.intraday.get_activity_intraday_by_date + self.get_activity_intraday_by_interval = self.intraday.get_activity_intraday_by_interval + self.get_breathing_rate_intraday_by_date = self.intraday.get_breathing_rate_intraday_by_date + self.get_breathing_rate_intraday_by_interval = ( + self.intraday.get_breathing_rate_intraday_by_interval + ) + self.get_heartrate_intraday_by_date = self.intraday.get_heartrate_intraday_by_date + self.get_heartrate_intraday_by_interval = self.intraday.get_heartrate_intraday_by_interval + self.get_hrv_intraday_by_date = self.intraday.get_hrv_intraday_by_date + self.get_hrv_intraday_by_interval = self.intraday.get_hrv_intraday_by_interval + self.get_spo2_intraday_by_date = self.intraday.get_spo2_intraday_by_date + self.get_spo2_intraday_by_interval = self.intraday.get_spo2_intraday_by_interval + + # Irregular Rhythm Notifications + self.get_irn_alerts_list = self.irregular_rhythm_notifications.get_irn_alerts_list + self.get_irn_profile = self.irregular_rhythm_notifications.get_irn_profile + + # Nutrition Timeseries + self.get_nutrition_timeseries_by_date = ( + self.nutrition_timeseries.get_nutrition_timeseries_by_date + ) + self.get_nutrition_timeseries_by_date_range = ( + self.nutrition_timeseries.get_nutrition_timeseries_by_date_range + ) + + # Nutrition + self.add_favorite_foods = self.nutrition.add_favorite_foods + self.add_favorite_food = self.nutrition.add_favorite_food + self.create_favorite_food = self.nutrition.create_favorite_food + self.create_food = self.nutrition.create_food + self.create_food_log = self.nutrition.create_food_log + self.create_food_goal = self.nutrition.create_food_goal + self.create_meal = self.nutrition.create_meal + self.create_water_goal = self.nutrition.create_water_goal + self.create_water_log = self.nutrition.create_water_log + self.delete_custom_food = self.nutrition.delete_custom_food + self.delete_favorite_foods = self.nutrition.delete_favorite_foods + self.delete_favorite_food = self.nutrition.delete_favorite_food + self.delete_food_log = self.nutrition.delete_food_log + self.delete_meal = self.nutrition.delete_meal + self.delete_water_log = self.nutrition.delete_water_log + self.get_food = self.nutrition.get_food + self.get_food_goals = self.nutrition.get_food_goals + self.get_food_log = self.nutrition.get_food_log + self.get_food_locales = self.nutrition.get_food_locales + self.get_food_units = self.nutrition.get_food_units + self.get_frequent_foods = self.nutrition.get_frequent_foods + self.get_recent_foods = self.nutrition.get_recent_foods + self.get_favorite_foods = self.nutrition.get_favorite_foods + self.get_meal = self.nutrition.get_meal + self.get_meals = self.nutrition.get_meals + self.get_water_goal = self.nutrition.get_water_goal + self.get_water_log = self.nutrition.get_water_log + self.search_foods = self.nutrition.search_foods + self.update_food_log = self.nutrition.update_food_log + self.update_meal = self.nutrition.update_meal + self.update_water_log = self.nutrition.update_water_log + + # Sleep + self.create_sleep_goals = self.sleep.create_sleep_goals + self.create_sleep_goal = self.sleep.create_sleep_goal + self.create_sleep_log = self.sleep.create_sleep_log + self.delete_sleep_log = self.sleep.delete_sleep_log + self.get_sleep_goals = self.sleep.get_sleep_goals + self.get_sleep_goal = self.sleep.get_sleep_goal + self.get_sleep_log_by_date = self.sleep.get_sleep_log_by_date + self.get_sleep_log_by_date_range = self.sleep.get_sleep_log_by_date_range + self.get_sleep_log_list = self.sleep.get_sleep_log_list + + # SpO2 + self.get_spo2_summary_by_date = self.spo2.get_spo2_summary_by_date + self.get_spo2_summary_by_interval = self.spo2.get_spo2_summary_by_interval + + # Subscription + self.get_subscription_list = self.subscription.get_subscription_list + + # Temperature + self.get_temperature_core_summary_by_date = ( + self.temperature.get_temperature_core_summary_by_date + ) + self.get_temperature_core_summary_by_interval = ( + self.temperature.get_temperature_core_summary_by_interval + ) + self.get_temperature_skin_summary_by_date = ( + self.temperature.get_temperature_skin_summary_by_date + ) + self.get_temperature_skin_summary_by_interval = ( + self.temperature.get_temperature_skin_summary_by_interval + ) + + # User + self.get_profile = self.user.get_profile + self.update_profile = self.user.update_profile + self.get_badges = self.user.get_badges + + self.logger.debug("Method aliases set up successfully")