From 112ff2f15ed8c40a2c33125b6ad0cf7bbd921df9 Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Thu, 6 Mar 2025 07:52:18 -0500 Subject: [PATCH 1/4] ignore TODO --- .gitignore | 2 ++ TODO.md | 26 +++++--------------------- 2 files changed, 7 insertions(+), 21 deletions(-) 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/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: From 89477213842c22c4edb6e88df8dbe2ba197d9a0c Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Thu, 6 Mar 2025 06:39:45 -0500 Subject: [PATCH 2/4] Corruption cleanup; merges a few commits: - Bring back resource method aliases - Streamline README - Improve documentation organization and readability - Split NAMING_AND_TYPING.md into TYPES.md and NAMING.md - Split VALIDATIONS_AND_EXCEPTIONS.md into VALIDATIONS.md and ERROR_HANDLING.md - Update README.md with organized documentation links - Fix intraday data support information in DEVELOPMENT.md - Add information about disabling data logging to LOGGING.md - Update TODO.md to reflect completed documentation work --- .gitignore | 2 + README.md | 91 +++--- docs/DEVELOPMENT.md | 94 +++++- docs/ERROR_HANDLING.md | 181 ++++++++++++ docs/LOGGING.md | 29 +- docs/NAMING.md | 59 ++++ docs/{NAMING_AND_TYPING.md => TYPES.md} | 104 ++++--- docs/VALIDATIONS.md | 200 +++++++++++++ docs/VALIDATIONS_AND_EXCEPTIONS.md | 367 ------------------------ fitbit_client/client.py | 200 ++++++++++++- tests/test_method_aliases.py | 58 ++++ 11 files changed, 924 insertions(+), 461 deletions(-) create mode 100644 docs/ERROR_HANDLING.md create mode 100644 docs/NAMING.md rename docs/{NAMING_AND_TYPING.md => TYPES.md} (70%) create mode 100644 docs/VALIDATIONS.md delete mode 100644 docs/VALIDATIONS_AND_EXCEPTIONS.md create mode 100644 tests/test_method_aliases.py 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 2f8c539..0ec7c02 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,63 +62,77 @@ 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`). -## Authentication Methods +## Method Aliases -### 1. Automatic (Recommended) - -Uses a local callback server to automatically handle the OAuth2 flow: +All resource methods are available directly from the client instance. This means +you can use: ```python -client = FitbitClient( - client_id="YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - redirect_uri="https://localhost:8080", - use_callback_server=True # default is True -) +# 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") +``` -# Will open browser and handle callback automatically -client.authenticate() +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") ``` -### 2. Manual URL Copy/Paste +Both approaches are equivalent, but aliases provide a more concise syntax. -If you prefer not to use a local server: +## Authentication + +Uses a local callback server to automatically handle the OAuth2 flow: ```python client = FitbitClient( client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", redirect_uri="YOUR_REGISTERED_REDIRECT_URI", - token_cache_path="/tmp/fb_tokens.json", - use_callback_server=True + token_cache_path="/tmp/fb_tokens.json" # Optional: saves tokens between sessions ) -# Will open a browser and start a server to complete the flow (default), or -# prompt you on the command line to copy/paste the callback URL from the -# browser (see `use_callback_server`) +# Will open browser and handle callback automatically client.authenticate() ``` +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 + ## 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: - - For automatic method: "https://localhost:8080" - - For manual method: Your preferred redirect URI +3. Set Callback URL to "https://localhost:8080" (or your preferred local URL) 4. Copy your Client ID and Client Secret -Additional documentation: +## Additional Documentation -- To understand the logging implemementation, see [LOGGING](docs/LOGGING.md) -- To understand validations and the exception hierarchy, see - [VALIDATIONS_AND_EXCEPTIONS](docs/VALIDATIONS_AND_EXCEPTIONS.md) -- It's work checking out - [Fitbit's Best Practices](https://dev.fitbit.com/build/reference/web-api/developer-guide/best-practices/) -- For some general development guidelines, see - [DEVELOPMENT](docs/DEVELOPMENT.md). -- For style guidelines (mostly enforced through varius linters and formatters) - see [STYLE](docs/STYLE.md). +### 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 ## Important Note - Subscription Support @@ -124,9 +141,13 @@ This client does not currently support the and [deletion](https://dev.fitbit.com/build/reference/web-api/subscription/delete-subscription/) of -[webhook subscrptions](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 a user confirm this. +[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! ## License diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 367c392..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 @@ -339,18 +399,32 @@ TODO - Use issue templates when reporting bugs - Include Python version and environment details in bug reports -## Scope and Limitations - Intraday Data Support +## Intraday Data Support -This client explicitly does not implement intraday data endpoints (detailed -heart rate, steps, etc). These endpoints: +This client implements intraday data endpoints (detailed heart rate, steps, etc) +through the `IntradayResource` class. These endpoints: - Require special access from Fitbit (typically limited to research applications) - Have different rate limits than standard endpoints -- Need additional OAuth2 scopes -- Often require institutional review board (IRB) approval - -If you need intraday data access: - -1. Apply through Fitbit's developer portal -2. Pull requests welcome! +- Need additional OAuth2 scopes (specifically the 'activity' and 'heartrate' + scopes) +- Often require institutional review board (IRB) approval for research + applications + +To use intraday data: + +1. Apply for intraday access through the + [Fitbit developer portal](https://dev.fitbit.com/) +2. Ensure your application requests the appropriate scopes +3. Use the intraday methods with appropriate detail level parameters: + ```python + client.intraday.get_heartrate_intraday_by_date( + date="today", + detail_level="1min" # or "1sec" depending on your access level + ) + ``` + +See the +[Intraday API documentation](https://dev.fitbit.com/build/reference/web-api/intraday/) +for more details on available endpoints and access requirements. diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md new file mode 100644 index 0000000..eda1cb6 --- /dev/null +++ b/docs/ERROR_HANDLING.md @@ -0,0 +1,181 @@ +# Exception Handling + +The library implements a comprehensive exception system to help you handle +errors effectively. Understanding these exceptions will help you write more +robust code. + +## 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 + │ ├── ExpiredTokenException # Raised when the OAuth token has expired + │ ├── InvalidGrantException # Raised when the grant_type value is invalid + │ ├── 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 +``` + +## Client Validation Exceptions + +Client validation exceptions (`ClientValidationException` and its subclasses) +are raised *before* any API call is made: + +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 + +```python +from fitbit_client.exceptions import InvalidDateException, InvalidDateRangeException + +try: + client.sleep.get_sleep_log_by_date_range( + start_date="2024-01-01", + end_date="2023-12-31" # End date before start date + ) +except InvalidDateRangeException as e: + print(f"Date range error: {e.message}") + print(f"Start date: {e.start_date}, End date: {e.end_date}") + print(f"Resource: {e.resource_name}, Max days: {e.max_days}") +``` + +### Common Client Validation Exceptions + +- **InvalidDateException**: Raised when a date string is not valid +- **InvalidDateRangeException**: Raised when a date range is invalid +- **ParameterValidationException**: Raised when a parameter value is invalid +- **MissingParameterException**: Raised when required parameters are missing +- **PaginationException**: Raised when pagination parameters are invalid +- **IntradayValidationException**: Raised when intraday request parameters are + invalid + +## API Exceptions + +API exceptions (`FitbitAPIException` and its subclasses) are raised in response +to errors returned by the Fitbit API: + +```python +from fitbit_client.exceptions import AuthorizationException, RateLimitExceededException + +try: + client.activity.get_lifetime_stats() +except AuthorizationException as e: + print(f"Auth error ({e.status_code}): {e.message}") + # Handle authentication error (e.g., refresh token, prompt for re-auth) +except RateLimitExceededException as e: + retry_after = int(e.headers.get("Retry-After", 60)) + print(f"Rate limit exceeded. Retry after {retry_after} seconds") + # Implement backoff strategy +``` + +### Common API Exceptions + +- **AuthorizationException**: Authentication or authorization issues +- **InvalidRequestException**: Invalid request syntax or parameters +- **RateLimitExceededException**: API rate limits exceeded +- **NotFoundException**: Requested resource doesn't exist +- **SystemException**: Fitbit API server-side errors + +## Exception Properties + +### Client Validation Exceptions + +All client validation exceptions have these properties: + +- `message`: Human-readable error description +- `field_name`: Name of the invalid field (if applicable) + +Specific validation exception types add additional properties: + +- **InvalidDateException**: `date_str` (the invalid date string) +- **InvalidDateRangeException**: `start_date`, `end_date`, `max_days`, + `resource_name` +- **IntradayValidationException**: `allowed_values`, `resource_name` + +### API Exceptions + +All API exceptions have 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) +- `headers`: Response headers (useful for rate limiting info) + +## Usage Patterns + +### Catching Specific Exceptions + +Target specific exceptions for tailored error handling: + +```python +try: + client.activity.create_activity_goals( + period=ActivityGoalPeriod.DAILY, + type=ActivityGoalType.STEPS, + value=-1000 + ) +except ParameterValidationException as e: + print(f"Invalid value for {e.field_name}: {e.message}") +except AuthorizationException as e: + print(f"Authorization error: {e.message}") +except RateLimitExceededException as e: + print(f"Rate limit error: {e.message}") +``` + +### Catching Base Exception Classes + +Catch base classes to handle related exceptions together: + +```python +try: + client.activity.get_daily_activity_summary("today") +except ClientValidationException as e: + print(f"Invalid input: {e.message}") # Catches all input validation errors +except OAuthException as e: + print(f"OAuth error: {e.message}") # Catches all OAuth-related errors +except FitbitAPIException as e: + print(f"API error: {e.message}") # Catches all other API errors +``` + +## Debugging APIs + +Every method accepts a `debug` parameter that prints the equivalent cURL +command: + +```python +client.activity.get_daily_activity_summary( + date="today", + debug=True +) +# Prints: +# curl -X GET -H "Authorization: Bearer " ... +``` + +This helps troubleshoot API interactions by showing the exact request being +made. diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 365f820..92d900a 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -79,10 +79,10 @@ The data logger uses INFO level for all entries, with a structured JSON format: ``` As you can see, this is really just summary of the response body that makes it -easy to get back in information you may not have captured in a one-off script. +easy to get back information you may not have captured in a one-off script. -Note that the log will not be valid JSON file, but each line will be a valid -object and it is be trivial to read it in at as `List[Dict[str, Any]]`. +Note that the log will not be a valid JSON file, but each line will be a valid +JSON object and it is trivial to read it in as `List[Dict[str, Any]]`. ## Important Fields @@ -129,6 +129,24 @@ data_logger.setLevel("INFO") data_logger.propagate = False # Prevent duplicate logging ``` +### Disabling Data Logging + +If you don't need the data logging feature, you can disable it completely by +setting the level to CRITICAL: + +```python +data_logger = getLogger("fitbit_client.data") +data_logger.setLevel("CRITICAL") # Effectively disables data logging +``` + +You can also disable it by not adding any handlers to the data logger: + +```python +data_logger = getLogger("fitbit_client.data") +data_logger.propagate = False # Ensures logs don't propagate to parent loggers +# No handlers added = no logging output +``` + ## Error Logging The client automatically logs all API errors with rich context including: @@ -138,3 +156,8 @@ The client automatically logs all API errors with rich context including: - Affected resource/endpoint - Field-specific validation errors - Raw error response when available + +## Cross-References + +For more information on error handling, see +[ERROR_HANDLING.md](ERROR_HANDLING.md). diff --git a/docs/NAMING.md b/docs/NAMING.md new file mode 100644 index 0000000..9ebc51d --- /dev/null +++ b/docs/NAMING.md @@ -0,0 +1,59 @@ +# API Method Naming Conventions + +## Naming Principles + +The method names in this library are designed to align with the official Fitbit +Web API Documentation. When there are inconsistencies in the official +documentation, we follow these principles: + +1. The URL slug in the documentation is the primary reference +2. Method names always use underscores (snake_case), not camelCase +3. The HTTP verb is reflected in the method name prefix: + - `get_`: For HTTP GET operations + - `create_`: For HTTP POST operations that create new resources + - `update_`: For HTTP PUT operations + - `delete_`: For HTTP DELETE operations + - `add_`: Only used in specific contexts where "add" is more intuitive than + "create" + +## API Documentation Alignment + +For any discrepancies between different parts of the official documentation, we +prioritize the URL slug. For example, if the documentation page title says "Get +Time Series by Date" but the URL is ".../get-azm-timeseries-by-date/", our +method will be named `get_azm_timeseries_by_date()`. + +This approach ensures consistent naming throughout the library and makes methods +easier to find based on the official documentation. + +## Inconsistencies in the API + +The Fitbit API contains several inconsistencies, which our method names +necessarily reflect: + +- `create_activity_goals` creates only one goal at a time +- `add_favorite_foods` adds one food at a time, while all other creation methods + use "create" prefix +- Some pluralized methods return lists, while others return dictionaries + containing lists + +## Method Aliases + +For user convenience, some methods have aliases: + +- `create_activity_goal` -> `create_activity_goals` +- `add_favorite_food` -> `add_favorite_foods` +- `create_favorite_food` -> `add_favorite_foods` +- `delete_favorite_food` -> `delete_favorite_foods` +- `create_sleep_goal` -> `create_sleep_goals` +- `get_sleep_goal` -> `get_sleep_goals` + +These aliases help accommodate different expectations and ensure backward +compatibility. + +## Resource Structure + +The client organizes API endpoints into logical resource classes, each +representing a different section of the Fitbit API. For a complete list of all +methods and their return types, see +[TYPES.md](TYPES.md#method-return-types-reference). diff --git a/docs/NAMING_AND_TYPING.md b/docs/TYPES.md similarity index 70% rename from docs/NAMING_AND_TYPING.md rename to docs/TYPES.md index 1ec4604..e7b30ec 100644 --- a/docs/NAMING_AND_TYPING.md +++ b/docs/TYPES.md @@ -1,29 +1,53 @@ -# Method Naming and Typing - -Typing and JSON is awkward, but it's useful to know what you should expect to -get back when you call a method. For this reason, all of the resource methods -(i.e. API endpoints) return either `JSONDict`, `JSONList`, `None`. In the case -of the latter two, you're on your own once you're inside the structure, but -knowing the wrapper is at least a good start at sanity. - -Note that we deviate from the native API a little bit in that the content-type -and response body of `delete_*` methods is not consistent: some return an empty -body, some return `null`, and at least one returns `{}`. Here's the deal: if the -reponse status from a API call is `204`, you will get `None`. This is -[in line with the documentation](https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/error-messages/#204-no-content). - -An area where it's tempting to deviate, but we don't, is in data structures in -the body of the responses. To start, the interns who developed the -[Web API](https://dev.fitbit.com/build/reference/web-api/) were not very -consistent with naming and typing of the API endpoints or the responses. Just a -few examples: - -- `create_activity_goals` only allows you to create one goal at a time -- `add_favorite_foods` adds one food at a time, and "add" is only used here. - It's "create" everywhere else. -- Method names that suggest they would return a list usually don't. They use - this structure (for example, from - `activity_timeseries#get_time_series_by_date`) +# JSON Type System + +## Overview + +This library uses a type system to help you understand what to expect from API +responses. All resource methods (API endpoints) return one of three types: + +- `JSONDict`: A dictionary containing JSON data +- `JSONList`: A list containing JSON data +- `None`: For operations that don't return data (typically delete operations) + +While Python's dynamic typing doesn't enforce these types at runtime, they +provide valuable documentation and enable IDE autocompletion. + +## Understanding Response Types + +### JSONDict and JSONList + +The base types represent the outermost wrapper of API responses: + +```python +# These are the actual definitions from utils/types.py +JSONDict = Dict[str, JSONType] # A dictionary with string keys and JSON values +JSONList = List[JSONType] # A list of JSON values +``` + +Where `JSONType` is a recursive type that can be any valid JSON value: + +```python +JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] +``` + +This typing helps you know the outer structure of the response but doesn't +specify the inner details. + +### Empty and Special Responses + +The library standardizes response handling in these cases: + +- HTTP 204 responses (No Content): Return `None` +- DELETE operations: Return `None` (regardless of how the API responds) +- Special formats like TCX/XML: Return as raw strings (not JSON) + +## Response Inconsistencies + +The Fitbit API has some inconsistencies in response structures. Some methods +that suggest they would return lists (by their plural names) actually return +dictionaries with embedded lists. + +For example, `get_activity_timeseries_by_date()` returns: ```json { @@ -40,15 +64,15 @@ few examples: } ``` -This would be a lovely and reliable convention! Except that: +This is typed as a `JSONDict`, not a `JSONList`, despite containing a list of +items. + +In contrast, these methods do return direct lists (typed as `JSONList`): ``` get_favorite_activities get_frequent_activities -get_recent_activity_types -get_favorite_activities -get_frequent_activities -get_recent_activity_types +get_recent_activity_types get_devices get_food_locales get_food_units @@ -57,19 +81,9 @@ get_recent_foods get_spo2_summary_by_interval ``` -All return lists. If there is a rhyme or reason for this, I've not found it yet. - -## Naming - -Methods are named exactly as they appear in the -[Web API Documentation](https://dev.fitbit.com/build/reference/web-api/). When -there are inconsistencies (frequently) the documentation;s URL slug is the -deciding factor. For example, for "Get AZM Time Series by Date" -https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/get-azm-timeseries-by-date/, -(which is it--"Time Series" or "timeseries"?) the method in our code will be -`get_azm_timeseries_by_date()`. +## Method Return Types Reference -## Method Return Types +Below is a comprehensive list of all method return types by resource class: ### ActiveZoneMinutesResource @@ -86,8 +100,8 @@ https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/ge - `create_activity_goals -> JSONDict` - `create_activity_goal -> JSONDict` (alias for create_activity_goals) - `create_activity_log -> JSONDict` -- `create_favorite_activity -> Dict[Never, Never]` ?? -- `delete_activity_log -> Dict[Never, Never]` ?? +- `create_favorite_activity -> Dict[Never, Never]` +- `delete_activity_log -> Dict[Never, Never]` - `get_activity_log_list -> JSONDict` - `delete_favorite_activity -> None` - `get_activity_goals -> JSONDict` diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md new file mode 100644 index 0000000..15feb94 --- /dev/null +++ b/docs/VALIDATIONS.md @@ -0,0 +1,200 @@ +# Input Validation + +The library performs thorough validation of input parameters **before making any +API requests**. This approach: + +- Preserves your API rate limits by catching errors locally +- Provides more specific and helpful error messages +- Simplifies debugging by separating client-side validation from API issues + +## Date Formats + +All dates must be in one of these formats: + +- YYYY-MM-DD (e.g., "2024-02-20") +- "today" (special keyword for current date) + +```python +# Valid dates +client.activity.get_daily_activity_summary("2024-02-20") +client.activity.get_daily_activity_summary("today") + +# Invalid - will raise InvalidDateException +try: + client.activity.get_daily_activity_summary("02-20-2024") +except InvalidDateException as e: + print(e.message) + # Output: Invalid date format. Use YYYY-MM-DD or 'today' +``` + +## Date Ranges + +When using endpoints that accept date ranges: + +- `start_date` must be before or equal to `end_date` +- Maximum range varies by endpoint: + - Body weight logs: 31 days + - Sleep logs: 100 days + - Activity data: 31 days + - General data: 1095 days (approximately 3 years) + +```python +# Valid range within limits +client.sleep.get_sleep_log_by_date_range( + start_date="2024-01-01", + end_date="2024-01-31" +) + +# Invalid - exceeds sleep data 100 day limit +try: + client.sleep.get_sleep_log_by_date_range( + start_date="2024-01-01", + end_date="2024-12-31" + ) +except InvalidDateRangeException as e: + print(e.message) + # Output: Date range cannot exceed 100 days for sleep time series +``` + +## Enumerated Values + +The library provides enums for many parameters to ensure valid values: + +```python +from fitbit_client.resources.constants import Period, ActivityGoalType + +# Valid - using provided enum +client.activity.get_activity_timeseries_by_date( + resource_path=ActivityTimeSeriesPath.STEPS, + date="today", + period=Period.ONE_WEEK +) + +# Invalid - will raise ValidationException +try: + client.activity.get_activity_timeseries_by_date( + resource_path=ActivityTimeSeriesPath.STEPS, + date="today", + period="invalid_period" + ) +except ValidationException as e: + print(e.message) + # Output: Invalid period value. Use one of: 1d, 7d, 30d, 1w, 1m, 3m, 6m, 1y, max +``` + +## Required Parameters + +Some methods require specific parameter combinations: + +```python +# Valid - using food_id +client.nutrition.create_food_log( + date="2024-02-20", + meal_type_id=MealType.BREAKFAST, + unit_id=1, + amount=1.0, + food_id=12345 # Option 1 +) + +# Valid - using food_name and calories +client.nutrition.create_food_log( + date="2024-02-20", + meal_type_id=MealType.BREAKFAST, + unit_id=1, + amount=1.0, + food_name="My Custom Food", # Option 2 + calories=200 # Option 2 +) + +# Invalid - missing both food_id and (food_name, calories) +try: + client.nutrition.create_food_log( + date="2024-02-20", + meal_type_id=MealType.BREAKFAST, + unit_id=1, + amount=1.0 + ) +except ValidationException as e: + print(e.message) + # Output: Must provide either food_id or (food_name and calories) +``` + +## Numeric Limits + +Many endpoints enforce numeric limits: + +```python +# Invalid - Cannot request more than 100 records +try: + client.get_activity_log_list(limit=200) +except ValidationException as e: + print(e.message) + # Output: Maximum limit is 100 records + +# Invalid - Goal value must be positive +try: + client.activity.create_activity_goals( + period=ActivityGoalPeriod.DAILY, + type=ActivityGoalType.STEPS, + value=-1000 + ) +except ValidationException as e: + print(e.message) + # Output: Goal value must be positive +``` + +## Pagination Parameters + +When using endpoints with pagination: + +- Only one of `offset`/`limit` or `before_date`/`after_date`/`sort` can be used +- `sort` must be consistent with the date parameter used (ascending with + `after_date`, descending with `before_date`) +- Maximum records per request is typically limited (often 100) + +```python +# Valid - using offset/limit pagination +client.activity.get_activity_log_list(offset=0, limit=10) + +# Valid - using date-based pagination +client.activity.get_activity_log_list( + after_date="2024-01-01", + sort="asc" +) + +# Invalid - mixing pagination methods +try: + client.activity.get_activity_log_list( + offset=0, + limit=10, + after_date="2024-01-01" + ) +except PaginationException as e: + print(e.message) + # Output: Cannot mix offset/limit with date-based pagination +``` + +## Custom and Combined Validation + +Some endpoints have custom validation requirements: + +```python +# Custom validation for food log creation +try: + client.nutrition.create_food_log( + date="2024-02-20", + meal_type_id=MealType.BREAKFAST, + food_name="Test Food", + # Missing calories! + unit_id=1, + amount=1.0 + ) +except MissingParameterException as e: + print(e.message) + # Output: When using food_name, calories is required +``` + +## Validation Implementation + +For a complete understanding of the exception system that powers these +validations, see [ERROR_HANDLING.md](ERROR_HANDLING.md). diff --git a/docs/VALIDATIONS_AND_EXCEPTIONS.md b/docs/VALIDATIONS_AND_EXCEPTIONS.md deleted file mode 100644 index 76752d1..0000000 --- a/docs/VALIDATIONS_AND_EXCEPTIONS.md +++ /dev/null @@ -1,367 +0,0 @@ -# Input Validation and Error Handling - -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 - -### Date Formats - -All dates must be in one of these formats: - -- YYYY-MM-DD (e.g., "2024-02-20") -- "today" (special keyword for current date) - -Example: - -```python -# Valid dates -client.activity.get_daily_activity_summary("2024-02-20") -client.activity.get_daily_activity_summary("today") - -# Invalid - will raise InvalidDateException -try: - client.activity.get_daily_activity_summary("02-20-2024") -except InvalidDateException as e: - print(e.message) - # Output: Invalid date format. Use YYYY-MM-DD or 'today' -``` - -### Date Ranges - -When using endpoints that accept date ranges: - -- start_date must be before or equal to end_date -- Maximum range varies by endpoint: - - Body weight logs: 31 days - - Sleep logs: 100 days - - Activity data: 31 days - - General data: 1095 days (approximately 3 years) - -Example: - -```python -# Valid range within limits -client.sleep.get_sleep_log_by_date_range( - start_date="2024-01-01", - end_date="2024-01-31" -) - -# Invalid - exceeds sleep data 100 day limit -try: - client.sleep.get_sleep_log_by_date_range( - start_date="2024-01-01", - end_date="2024-12-31" - ) -except InvalidDateRangeException as e: - print(e.message) - # Output: Date range cannot exceed 100 days for sleep time series -``` - -### Enumerated Values - -The library provides enums for many parameters. Using these enums ensures valid -values: - -```python -from fitbit_client.resources.constants import Period, ActivityGoalType - -# Valid - using provided enum -client.activity.get_activity_timeseries_by_date( - resource_path=ActivityTimeSeriesPath.STEPS, - date="today", - period=Period.ONE_WEEK -) -``` - -### Required Parameters - -Some methods require specific parameter combinations that can be tricky to get -right. For example, creating a food log requires either a `food_id`, or -`food_name` AND `calories`. - -```python -# Valid - using food_id -client.nutrition.create_food_log( - date="2024-02-20", - meal_type_id=MealType.BREAKFAST, - unit_id=1, - amount=1.0, - food_id=12345 # Option 1 -) - -# Valid - using food_name and calories -client.nutrition.create_food_log( - date="2024-02-20", - meal_type_id=MealType.BREAKFAST, - unit_id=1, - amount=1.0, - food_name="My Custom Food", # Option 2 - calories=200 # Option 2 -) - -# Invalid - missing both food_id and (food_name, calories) -try: - client.nutrition.create_food_log( - date="2024-02-20", - meal_type_id=MealType.BREAKFAST, - unit_id=1, - amount=1.0 - ) -except ValidationException as e: - print(e.message) - # Output: Must provide either food_id or (food_name and calories) - -# Invalid - only provided food_name without calories -try: - client.nutrition.create_food_log( - date="2024-02-20", - meal_type_id=MealType.BREAKFAST, - unit_id=1, - amount=1.0, - food_name="My Custom Food" - ) -except ValidationException as e: - print(e.message) - # Output: Must provide either food_id or (food_name and calories) -``` - -### Numeric Limits - -Many endpoints enforce numeric limits: - -```python -# Invalid - Cannot request more than 100 records -try: - client.get_activity_log_list(limit=200) -except ValidationException as e: - print(e.message) - # Output: Maximum limit is 100 records - -# Invalid - Goal value must be positive -try: - client.activity.create_activity_goals( - period=ActivityGoalPeriod.DAILY, - type=ActivityGoalType.STEPS, - value=-1000 - ) -except ValidationException as e: - print(e.message) - # Output: Goal value must be positive - -# Invalid - Duration must be positive -try: - client.activity.create_activity_log( - activity_id=12345, - duration_millis=-60000, # -1 minute - start_time="12:00", - date="2024-02-20" - ) -except ValidationException as e: - print(e.message) - # Output: Duration must be positive -``` - -## Exception Handling - -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: - -```python -try: - # Value must be positive - client.activity.create_activity_goal( - period=ActivityGoalPeriod.DAILY, - type=ActivityGoalType.STEPS, - value=-100 - ) -except ValidationException as e: - print(f"Field '{e.field_name}' invalid: {e.message}") - # Output: Field 'value' invalid: Goal value must be positive -``` - -### InvalidDateException - -Raised when a date format is invalid: - -```python -try: - client.activity.get_daily_activity_summary("01-01-2024") -except InvalidDateException as e: - print(e.message) - # Output: Invalid date format. Use YYYY-MM-DD or 'today' -``` - -### InvalidDateRangeException - -Raised when date ranges are invalid or exceed limits: - -```python -try: - client.sleep.get_sleep_log_by_date_range( - start_date="2024-01-01", - end_date="2023-12-31" # End date before start date - ) -except InvalidDateRangeException as e: - print(e.message) -``` - -### AuthorizationException - -Raised for authentication or authorization issues: - -```python -try: - client.activity.get_lifetime_stats() -except AuthorizationException as e: - print(f"Auth error ({e.status_code}): {e.message}") -``` - -### RateLimitExceededException - -Raised when you've exceeded Fitbit's API rate limits: - -```python -try: - client.activity.get_daily_activity_summary("today") -except RateLimitExceededException as e: - print(f"Rate limit exceeded: {e.message}") - # Implement appropriate backoff strategy -``` - -### Exception 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 - │ ├── ExpiredTokenException # Raised when the OAuth token has expired - │ ├── InvalidGrantException # Raised when the grant_type value is invalid - │ ├── 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 -``` - -## Debugging - -Every method accepts a `debug` parameter that prints the equivalent cURL -command: - -```python -client.activity.get_daily_activity_summary( - date="today", - debug=True -) -# Prints: -# curl -X GET -H "Authorization: Bearer " ... -``` - -This can help troubleshoot API interactions by showing the exact request being -made. 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") diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py new file mode 100644 index 0000000..97ee883 --- /dev/null +++ b/tests/test_method_aliases.py @@ -0,0 +1,58 @@ +# tests/test_method_aliases.py + +# Third party imports +import pytest + +# Local imports +from fitbit_client.client import FitbitClient + + +class TestMethodAliases: + """Test that all resource methods are properly aliased in the client.""" + + def test_method_aliases_implementation(self): + """Verify that the client has set up method aliases for all resources.""" + # Check that the client file has the required method and call + with open( + "/Users/jstroop/workspace/fitbit-client-python/fitbit_client/client.py", "r" + ) as f: + client_content = f.read() + + # Check that the _setup_method_aliases method exists + assert ( + "def _setup_method_aliases" in client_content + ), "The _setup_method_aliases method is missing" + + # Check that it's called in __init__ + assert ( + "self._setup_method_aliases()" in client_content + ), "_setup_method_aliases() is not called in __init__" + + # Check that there are assignments for all resources + resources = [ + "active_zone_minutes", + "activity", + "activity_timeseries", + "body", + "body_timeseries", + "breathing_rate", + "cardio_fitness_score", + "device", + "electrocardiogram", + "friends", + "heartrate_timeseries", + "heartrate_variability", + "intraday", + "irregular_rhythm_notifications", + "nutrition", + "nutrition_timeseries", + "sleep", + "spo2", + "subscription", + "temperature", + "user", + ] + + for resource in resources: + # Check at least one method from each resource is aliased + assert f"self.{resource}." in client_content, f"No method aliases found for {resource}" From 59991c76f6ed89dffd0e864d6278e357da359456 Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Thu, 6 Mar 2025 20:34:37 -0500 Subject: [PATCH 3/4] formatting fix --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8f0f70b..2c04afa 100644 --- a/README.md +++ b/README.md @@ -107,22 +107,26 @@ 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 -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) -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 @@ -142,6 +146,7 @@ for API usage. - [DEVELOPMENT.md](docs/DEVELOPMENT.md): Development environment and guidelines - [STYLE.md](docs/STYLE.md): Code style and formatting standards + ## Additional Documentation ### For API Library Users From 6db86eb81077ac049c8f6422767fd0baeeca44c6 Mon Sep 17 00:00:00 2001 From: Jon Stroop Date: Thu, 6 Mar 2025 20:38:12 -0500 Subject: [PATCH 4/4] remove test_method_aliases.py --- tests/test_method_aliases.py | 58 ------------------------------------ 1 file changed, 58 deletions(-) delete mode 100644 tests/test_method_aliases.py diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py deleted file mode 100644 index 97ee883..0000000 --- a/tests/test_method_aliases.py +++ /dev/null @@ -1,58 +0,0 @@ -# tests/test_method_aliases.py - -# Third party imports -import pytest - -# Local imports -from fitbit_client.client import FitbitClient - - -class TestMethodAliases: - """Test that all resource methods are properly aliased in the client.""" - - def test_method_aliases_implementation(self): - """Verify that the client has set up method aliases for all resources.""" - # Check that the client file has the required method and call - with open( - "/Users/jstroop/workspace/fitbit-client-python/fitbit_client/client.py", "r" - ) as f: - client_content = f.read() - - # Check that the _setup_method_aliases method exists - assert ( - "def _setup_method_aliases" in client_content - ), "The _setup_method_aliases method is missing" - - # Check that it's called in __init__ - assert ( - "self._setup_method_aliases()" in client_content - ), "_setup_method_aliases() is not called in __init__" - - # Check that there are assignments for all resources - resources = [ - "active_zone_minutes", - "activity", - "activity_timeseries", - "body", - "body_timeseries", - "breathing_rate", - "cardio_fitness_score", - "device", - "electrocardiogram", - "friends", - "heartrate_timeseries", - "heartrate_variability", - "intraday", - "irregular_rhythm_notifications", - "nutrition", - "nutrition_timeseries", - "sleep", - "spo2", - "subscription", - "temperature", - "user", - ] - - for resource in resources: - # Check at least one method from each resource is aliased - assert f"self.{resource}." in client_content, f"No method aliases found for {resource}"