diff --git a/README.md b/README.md index da30351..d456921 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ 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 +- [LINTING.md](docs/LINTING.md): Code style and linting configuration ## Important Note - Subscription Support diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1e17077..5c2c8d8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -26,7 +26,6 @@ - [Response Mocking](#response-mocking) - [OAuth Callback Implementation](#oauth-callback-implementation) - [Implementation Flow](#implementation-flow) - - [Security Notes](#security-notes) - [Git Workflow](#git-workflow) - [Release Process](#release-process) - [Getting Help](#getting-help) @@ -45,8 +44,8 @@ 1. Clone the repository: ```bash -git clone https://github.com/yourusername/fitbit-client.git -cd fitbit-client +git clone https://github.com/jpstroop/fitbit-client-python.git +cd fitbit-client-python ``` 2. Install asdf plugins and required versions: diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index eda1cb6..5669bfb 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -163,6 +163,95 @@ except FitbitAPIException as e: print(f"API error: {e.message}") # Catches all other API errors ``` +### Handling OAuth-Specific Exceptions + +OAuth errors require special handling. Here's how to handle different OAuth +exception subtypes: + +```python +from fitbit_client.exceptions import ExpiredTokenException, InvalidTokenException +from fitbit_client.exceptions import InvalidClientException, InvalidGrantException + +try: + # Make an API call that requires authentication + client.get_profile() +except ExpiredTokenException as e: + # Token has expired but couldn't be auto-refreshed + print(f"Token expired: {e.message}") + # Attempt to re-authenticate + client.authenticate() +except InvalidTokenException as e: + # Token is invalid (e.g., revoked by user) + print(f"Token invalid: {e.message}") + # Re-authenticate from scratch + client.authenticate() +except InvalidClientException as e: + # Client credentials are incorrect + print(f"Client credentials error: {e.message}") + # Check client_id and client_secret +except InvalidGrantException as e: + # Refresh token is invalid + print(f"Invalid refresh token: {e.message}") + # Re-authenticate to get a new refresh token + client.authenticate() +``` + +## Token Refresh Strategies + +The client automatically handles token refresh when tokens expire. However, you +may want to implement custom token refresh strategies for your application. + +### Automatic Token Refresh + +By default, the client refreshes tokens automatically: + +1. When initializing the client with cached tokens +2. During API calls when a token expires +3. When explicitly calling a method that requires authentication + +```python +# Tokens are automatically refreshed +client = FitbitClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + redirect_uri="https://localhost:8080", + token_cache_path="/path/to/tokens.json" # Enable persistent token caching +) + +# If cached tokens exist and are valid or can be refreshed, no browser prompt occurs +client.authenticate() + +# If the token expires during this call, it will be refreshed automatically +client.get_profile() +``` + +### Handling Failed Token Refresh + +When automatic token refresh fails, the client raises an appropriate OAuth +exception. Here's a complete error handling pattern: + +```python +from fitbit_client.exceptions import OAuthException, ExpiredTokenException + +try: + result = client.get_profile() + # Process result normally +except ExpiredTokenException: + # Token expired and auto-refresh failed + try: + # Try to authenticate again + client.authenticate() + # Retry the original request + result = client.get_profile() + except OAuthException as oauth_error: + # Handle authentication failure (e.g., user closed browser window) + print(f"Authentication failed: {oauth_error.message}") + # Log the user out or provide error message +except OAuthException as e: + # Handle other OAuth errors + print(f"OAuth error: {e.message}") +``` + ## Debugging APIs Every method accepts a `debug` parameter that prints the equivalent cURL diff --git a/docs/LINTING.md b/docs/LINTING.md new file mode 100644 index 0000000..da42053 --- /dev/null +++ b/docs/LINTING.md @@ -0,0 +1,69 @@ +# Code Style and Linting + +Linting and formatting are handled by [Black](https://github.com/psf/black), +[isort](https://github.com/pycqa/isort/), +[mdformat](https://github.com/executablebooks/mdformat), +[autoflake](https://github.com/PyCQA/autoflake) and a +[small script that adds a path comment](../lint/add_file_headers.py). + +## Running the Linters + +You can run all formatters using: + +```bash +pdm run format +``` + +This will: + +- Format Python code with Black +- Sort imports with isort +- Format Markdown files with mdformat +- Remove unused imports with autoflake +- Add file path headers to Python files + +## Code Organization + +Every Python file follows a precise organizational structure with three distinct +import sections: + +1. **Standard library imports** - marked with `# Standard library imports` +2. **Third-party imports** - marked with `# Third party imports` +3. **Project-specific imports** - marked with `# Local imports` + +Each section should be alphabetically ordered and separated by exactly one blank +line. + +### Example + +```python +"""Module docstring explaining the purpose of this file.""" + +# Standard library imports +from datetime import datetime +from inspect import currentframe +from json import JSONDecodeError +from json import dumps +from typing import Any +from typing import Dict + +# Third party imports +from requests import Response +from requests_oauthlib import OAuth2Session + +# Local imports +from fitbit_client.exceptions import FitbitAPIException +from fitbit_client.resources.base import BaseResource +``` + +## Documentation Requirements + +The test suite verifies that all public methods have comprehensive docstrings +that follow the Google style format with specific sections: + +- Args +- Returns +- Raises +- Note (if applicable) + +Our linting tools ensure consistent style throughout the codebase. diff --git a/docs/NAMING.md b/docs/NAMING.md index 9ebc51d..18e3b10 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -28,15 +28,96 @@ easier to find based on the official documentation. ## Inconsistencies in the API -The Fitbit API contains several inconsistencies, which our method names -necessarily reflect: +The Fitbit API contains several inconsistencies, which our method names and +implementation necessarily reflect. Understanding these inconsistencies can help +you navigate the API more effectively: -- `create_activity_goals` creates only one goal at a time +### Method Name vs. Functionality Inconsistencies + +- `create_activity_goals` creates only one goal at a time, despite the plural + name - `add_favorite_foods` adds one food at a time, while all other creation methods use "create" prefix +- `get_sleep_goals` returns a single goal, not multiple goals - Some pluralized methods return lists, while others return dictionaries containing lists +### Parameter and Response Format Inconsistencies + +```python +# Sleep endpoint uses a different API version than most other endpoints +client.sleep.get_sleep_log_by_date(date="2025-01-01") # Uses API v1.2 +client.activity.get_daily_activity_summary(date="2025-01-01") # Uses API v1 + +# Sleep date parameters have inconsistent response behavior +# The request uses "2025-01-01" but response might contain data from "2025-01-02" +sleep_data = client.get_sleep_log_by_date(date="2025-01-01") +# A sleep log started on 2025-01-01 at 23:00 and ended on 2025-01-02 at 07:00 +# will be included in the response, but with dateOfSleep="2025-01-02" + +# Pagination parameters vary across endpoints +# Some endpoints require offset=0 +food_log = client.get_food_log(date="2025-01-01", offset=0) # Valid +# Others support arbitrary offsets +badges = client.get_badges(offset=20) # Also valid + +# Date range validations vary by endpoint +# Sleep endpoints allow up to 100 days +sleep_logs = client.get_sleep_log_by_date_range( + start_date="2025-01-01", + end_date="2025-04-10" # 100 days later, valid for sleep endpoint +) +# Activity endpoints allow only 31 days +activity_logs = client.get_activity_log_list( + before_date="2025-02-01", + after_date="2025-01-01" # 31 days earlier, valid for activity endpoint +) +``` + +### Response Structure Inconsistencies + +The structure of API responses varies widely across endpoints: + +```python +# Some endpoints return arrays directly +activities = client.get_frequent_activities() +# activities is a List[Dict[str, Any]] (array of activity objects) + +# Others wrap arrays in a parent object with a named property +sleep_logs = client.get_sleep_log_by_date(date="2025-01-01") +# sleep_logs is a Dict[str, Any] with a "sleep" property containing the array + +# Some endpoints use plural property names for lists +weight_logs = client.get_weight_logs(date="2025-01-01") +# weight_logs["weight"] is the list of weight logs + +# Others use singular property names for lists +food_logs = client.get_food_log(date="2025-01-01") +# food_logs["foods"] is the list of food logs +``` + +### Error Response Inconsistencies + +Error responses can also vary in structure: + +```python +try: + # Some validation errors include field names + client.create_food_log(food_id="invalid", amount=100, meal_type_id=1) +except ValidationException as e: + print(e.field_name) # Might be "foodId" + +try: + # Other validation errors omit field names + client.get_activity_tcx(log_id="invalid") +except InvalidRequestException as e: + print(e.field_name) # Might be None +``` + +Our library handles these inconsistencies internally to provide a unified +experience, but it's helpful to be aware of them when working with the raw API +responses. + ## Method Aliases For user convenience, some methods have aliases: diff --git a/docs/STYLE.md b/docs/STYLE.md deleted file mode 100644 index a751ea5..0000000 --- a/docs/STYLE.md +++ /dev/null @@ -1,251 +0,0 @@ -# Code Style - -Linting and formatting are handled by [Black](https://github.com/psf/black), -[isort](https://github.com/pycqa/isort/), -[mdformat](https://github.com/pycqa/isort/), -[autoflake](https://github.com/PyCQA/autoflake) and a -[small script that adds a path comment](lint/add_file_headers.py). That said, -here are some general guidelines: - -## Code Organization and Structure - -Every Python file in the codebase must follow a precise organizational structure -that begins with the module-level docstring, followed by three distinct import -sections. These sections are separated by exactly one blank line between them -and are marked with specific comments. - -The first section contains standard library imports with the comment "# Standard -library imports". These imports must be in alphabetical order. Related imports -from the same module, particularly typing imports, must be grouped together. For -example: - -```python -# 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 -``` - -The second section contains third-party imports with the comment "# Third party -imports". These must also be alphabetically ordered: - -```python -# Third party imports -from requests import Response -from requests_oauthlib import OAuth2Session -``` - -The third section contains project-specific imports with the comment "# Local -imports". These follow the same alphabetical ordering: - -```python -# Local imports -from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS -from fitbit_client.exceptions import FitbitAPIException -from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS -``` - -## Documentation Requirements - -The codebase demonstrates strict documentation requirements at multiple levels. -Every module must begin with a comprehensive docstring that explains its -purpose, notes important details, and includes the API reference URL where -applicable. For example: - -```python -""" -Handles Fitbit Active Zone Minutes (AZM) API endpoints for retrieving user's -heart-pumping activity data throughout the day. - -Active Zone Minutes (AZM) measure the time spent in target heart rate zones. -Different zones contribute differently to the total AZM count: -- Fat Burn zone: 1 minute = 1 AZM -- Cardio zone: 1 minute = 2 AZM -- Peak zone: 1 minute = 2 AZM - -API Reference: https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/ -""" -``` - -Every method must have a complete docstring that follows the Google style format -with specific sections. These sections must appear in the following order: Args, -Returns, Raises, and Note. The docstring must describe all parameters, including -their optionality and default values. For example: - -```python -def get_activity_tcx( - self, - log_id: int, - include_partial_tcx: bool = False, - user_id: str = "-", - debug: bool = False, -) -> Dict[str, Any]: - """ - Retrieves the TCX (Training Center XML) data for a specific activity log. - - TCX files contain GPS, heart rate, and lap data recorded during the logged exercise. - - Args: - log_id: ID of the activity log to retrieve - include_partial_tcx: Include TCX points when GPS data is not available. - Defaults to False. - user_id: Optional user ID. Defaults to "-" for current logged-in user. - debug: If True, prints a curl command to stdout to help with debugging. - Defaults to False. - - Returns: - Response contains TCX data for the activity including GPS, heart rate, - and lap information. - - Raises: - InvalidDateException: If date format is invalid - ValidationException: If log_id is invalid - AuthorizationException: If missing required scopes - - Note: - Requires both 'activity' and 'location' scopes to be authorized. - """ -``` - -## Testing Requirements - -The test files demonstrate comprehensive requirements for test coverage and -organization. Each test file must be named test\_[resource_name].py and contain -a primary test class named Test[ResourceName]. For example, the tests for -activity.py must be in test_activity.py with a class named TestActivityResource. - -Every test class must include fixtures that properly mock all dependencies. The -standard fixture pattern shown throughout the codebase is: - -```python -@fixture -def resource_name(self, mock_oauth_session, mock_logger): - """Create ResourceName instance with mocked dependencies""" - with patch("fitbit_client.resources.base.getLogger", return_value=mock_logger): - return ResourceName(mock_oauth_session, "en_US", "en_US") -``` - -Test coverage must be comprehensive, including both public and private methods. -Each test method must focus on a single behavior or condition and include -detailed verification of the expected outcome. The test name must clearly -describe what is being tested. For example: - -```python -def test_get_activity_tcx_with_partial_data(self, activity_resource, mock_response): - """Test retrieval of TCX data with partial data flag enabled""" - mock_response.json.return_value = {"expected": "response"} - activity_resource.oauth.request.return_value = mock_response - - result = activity_resource.get_activity_tcx( - log_id=12345, - include_partial_tcx=True - ) - - assert result == {"expected": "response"} - activity_resource.oauth.request.assert_called_once_with( - "GET", - "https://api.fitbit.com/1/user/-/activities/12345.tcx", - params={"includePartialTCX": True}, - data=None, - json=None, - headers={"Accept-Locale": "en_US", "Accept-Language": "en_US"} - ) -``` - -Error cases must be thoroughly tested using pytest's raises context manager, -verifying both the exception type and its attributes: - -```python -def test_get_activity_tcx_invalid_log_id(self, activity_resource): - """Test that invalid log ID raises ValidationException""" - with raises(ValidationException) as exc_info: - activity_resource.get_activity_tcx(log_id=-1) - - assert exc_info.value.status_code == 400 - assert exc_info.value.error_type == "validation" - assert exc_info.value.field_name == "log_id" - assert "Invalid log ID" in str(exc_info.value) -``` - -## Exception Handling - -The codebase defines a strict exception hierarchy based on FitbitAPIException. -All custom exceptions must inherit from this base class and include specific -attributes: - -```python -class CustomException(FitbitAPIException): - """Raised when [specific condition occurs]""" - - def __init__( - self, - message: str, - status_code: int, - error_type: str, - field_name: Optional[str] = None - ): - super().__init__( - message=message, - status_code=status_code, - error_type=error_type, - field_name=field_name - ) -``` - -The codebase maintains two comprehensive exception mappings: - -1. STATUS_CODE_EXCEPTIONS maps HTTP status codes to specific exception classes -2. ERROR_TYPE_EXCEPTIONS maps Fitbit API error types to exception classes - -## Resource Implementation - -All resource classes must inherit from BaseResource and follow consistent -patterns for method implementation. Every method that interacts with the API -must: - -1. Use type hints for all parameters and return values -2. Provide a default of "-" for user_id parameter -3. Include a debug parameter defaulting to False -4. Use appropriate validation decorators for dates and ranges -5. Follow consistent naming conventions (get\_, create\_, delete\_) - -Method implementation must handle API responses appropriately: - -```python -def get_example_data( - self, - param: str, - user_id: str = "-", - debug: bool = False -) -> Dict[str, Any]: - """Method docstring following standard format""" - return self._make_request( - f"endpoint/{param}.json", - user_id=user_id, - debug=debug - ) -``` - -## Date Handling - -The codebase shows strict requirements for date handling through two key -decorators: - -1. @validate_date_param() for single dates -2. @validate_date_range_params() for date ranges - -These decorators enforce: - -- YYYY-MM-DD format -- Acceptance of "today" as a valid value -- Proper ordering of date ranges -- Maximum range limits per endpoint - -The implementation must use these decorators consistently for all date-related -parameters.