diff --git a/README.md b/README.md index d81c70d..e340d9d 100644 --- a/README.md +++ b/README.md @@ -123,15 +123,43 @@ The client automatically retries on: ### Each endpoint supports: - `get_xxx_documents()` - Get multiple documents with pagination - `get_xxx_document(document_id)` - Get a single document by ID +- `stream()` - **NEW!** Stream all documents automatically handling pagination - Date filtering with `start_date` and `end_date` (accepts strings or date objects) - Pagination with `next_token` ## Advanced Usage -### Pagination +### Seamless Pagination with Stream Methods + +The easiest way to work with paginated data is using the `.stream()` methods, which automatically handle pagination and yield individual items: + +```python +# Stream all sleep data seamlessly - no manual pagination needed! +for sleep_record in client.daily_sleep.stream(start_date="2024-01-01"): + print(f"Date: {sleep_record.day}, Score: {sleep_record.score}") + +# Stream heart rate data for analysis +for hr_sample in client.heartrate.stream(start_date="2024-12-20"): + print(f"Heart rate: {hr_sample.bpm} at {hr_sample.timestamp}") + +# Stream activity data with date range +for activity in client.daily_activity.stream( + start_date="2024-01-01", + end_date="2024-01-31" +): + print(f"Steps: {activity.steps}, Calories: {activity.active_calories}") + +# Stream session data +for session in client.session.stream(start_date="2024-01-01"): + print(f"Session: {session.type} from {session.start_datetime}") +``` + +### Manual Pagination (Advanced) + +For more control over pagination, you can still handle it manually: ```python -# Iterate through all sleep data using pagination +# Iterate through all sleep data using manual pagination next_token = None all_sleep_data = [] diff --git a/demo_pagination.py b/demo_pagination.py new file mode 100644 index 0000000..226d7e5 --- /dev/null +++ b/demo_pagination.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Demonstration of the new pagination helpers in the Oura API client. + +This script shows how to use the new .stream() methods for seamless pagination. +""" + +import os +from datetime import date, timedelta +from oura_api_client.api.client import OuraClient + + +def demo_pagination_helpers(): + """Demonstrate the pagination helpers functionality.""" + + print("šŸ”„ Oura API Pagination Helpers Demo") + print("=" * 50) + + # Note: This demo shows the API usage but won't make real calls + # without a valid access token and data + + print("\n1. Setting up client...") + # Use a dummy token for demo (replace with real token for actual use) + client = OuraClient(access_token="demo_token_here") + + print("\n2. NEW: Seamless pagination with .stream() methods") + print("-" * 50) + + # Calculate date range for last 7 days + end_date = date.today() + start_date = end_date - timedelta(days=7) + + print(f"\nšŸ›Œ Streaming sleep data from {start_date} to {end_date}:") + print("for sleep_record in client.daily_sleep.stream(start_date='2024-01-01'):") + print(" print(f'Date: {sleep_record.day}, Score: {sleep_record.score}')") + + print(f"\nšŸ’“ Streaming heart rate data:") + print("for hr_sample in client.heartrate.stream(start_date='2024-01-01'):") + print(" print(f'HR: {hr_sample.bpm} at {hr_sample.timestamp}')") + + print(f"\nšŸƒ Streaming activity data:") + print("for activity in client.daily_activity.stream(start_date='2024-01-01'):") + print(" print(f'Steps: {activity.steps}, Calories: {activity.active_calories}')") + + print(f"\n⚔ Streaming readiness data:") + print("for readiness in client.daily_readiness.stream(start_date='2024-01-01'):") + print(" print(f'Readiness: {readiness.score}')") + + print(f"\nšŸ‹ļø Streaming session data:") + print("for session in client.session.stream(start_date='2024-01-01'):") + print(" print(f'Session: {session.type} from {session.start_datetime}')") + + print("\n3. Benefits of the new approach:") + print("-" * 50) + print("āœ… No manual pagination logic needed") + print("āœ… Memory efficient - items yielded one at a time") + print("āœ… Pythonic iteration with simple for loops") + print("āœ… Automatic handling of next_token parameters") + print("āœ… Works with date ranges and filtering") + print("āœ… No breaking changes to existing code") + + print("\n4. Advanced: Collecting all data") + print("-" * 50) + print("# Collect all items into a list if needed:") + print("all_sleep_data = list(client.daily_sleep.stream(start_date='2024-01-01'))") + print("print(f'Total records: {len(all_sleep_data)}')") + + print("\n5. Advanced: Processing with filters") + print("-" * 50) + print("# Process only high-quality sleep records:") + print("high_quality_sleep = [") + print(" record for record in client.daily_sleep.stream(start_date='2024-01-01')") + print(" if record.score and record.score > 80") + print("]") + + print("\nšŸŽ‰ The pagination helpers make working with Oura data much simpler!") + print(" Check the README for more examples and usage patterns.") + + +if __name__ == "__main__": + demo_pagination_helpers() \ No newline at end of file diff --git a/oura_api_client/api/base.py b/oura_api_client/api/base.py index 1e15265..e4faf2e 100644 --- a/oura_api_client/api/base.py +++ b/oura_api_client/api/base.py @@ -1,3 +1,36 @@ +from typing import Iterator, Callable, TypeVar, Any, Optional, Union +from datetime import date +from ..utils.pagination import stream_paginated_data + +T = TypeVar('T') + + class BaseRouter: def __init__(self, client): self.client = client + + def _stream_documents( + self, + fetch_function: Callable[..., Any], + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + **kwargs: Any + ) -> Iterator[T]: + """ + Stream all documents from a paginated endpoint. + + Args: + fetch_function: The endpoint method to call for fetching documents + start_date: Optional start date for filtering + end_date: Optional end date for filtering + **kwargs: Additional parameters to pass to the fetch function + + Yields: + Individual document items from the API response + """ + return stream_paginated_data( + fetch_function, + start_date=start_date, + end_date=end_date, + **kwargs + ) diff --git a/oura_api_client/api/daily_activity.py b/oura_api_client/api/daily_activity.py index c01e17b..66796a0 100644 --- a/oura_api_client/api/daily_activity.py +++ b/oura_api_client/api/daily_activity.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Optional, Union, Iterator from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.models.daily_activity import DailyActivityResponse, DailyActivityModel @@ -45,3 +45,28 @@ def get_daily_activity_document( f"/usercollection/daily_activity/{document_id}" ) return DailyActivityModel(**response) + + def stream( + self, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + ) -> Iterator[DailyActivityModel]: + """ + Stream all daily activity documents automatically handling pagination. + + Args: + start_date: Start date for the period. + end_date: End date for the period. + + Yields: + DailyActivityModel: Individual daily activity documents. + + Example: + >>> for activity in client.daily_activity.stream(start_date="2024-01-01"): + ... print(f"Steps: {activity.steps}") + """ + return self._stream_documents( + self.get_daily_activity_documents, + start_date=start_date, + end_date=end_date + ) diff --git a/oura_api_client/api/daily_readiness.py b/oura_api_client/api/daily_readiness.py index 4494ff8..cd67421 100644 --- a/oura_api_client/api/daily_readiness.py +++ b/oura_api_client/api/daily_readiness.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Optional, Union, Iterator from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params @@ -48,3 +48,28 @@ def get_daily_readiness_document( f"/usercollection/daily_readiness/{document_id}" ) return DailyReadinessModel(**response) + + def stream( + self, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + ) -> Iterator[DailyReadinessModel]: + """ + Stream all daily readiness documents automatically handling pagination. + + Args: + start_date: Start date for the period. + end_date: End date for the period. + + Yields: + DailyReadinessModel: Individual daily readiness documents. + + Example: + >>> for readiness in client.daily_readiness.stream(start_date="2024-01-01"): + ... print(f"Readiness score: {readiness.score}") + """ + return self._stream_documents( + self.get_daily_readiness_documents, + start_date=start_date, + end_date=end_date + ) diff --git a/oura_api_client/api/daily_sleep.py b/oura_api_client/api/daily_sleep.py index ce0c29c..1e124f3 100644 --- a/oura_api_client/api/daily_sleep.py +++ b/oura_api_client/api/daily_sleep.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Optional, Union, Iterator from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params @@ -46,3 +46,28 @@ def get_daily_sleep_document(self, document_id: str) -> DailySleepModel: f"/usercollection/daily_sleep/{document_id}" ) return DailySleepModel(**response) + + def stream( + self, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + ) -> Iterator[DailySleepModel]: + """ + Stream all daily sleep documents automatically handling pagination. + + Args: + start_date: Start date for the period. + end_date: End date for the period. + + Yields: + DailySleepModel: Individual daily sleep documents. + + Example: + >>> for sleep_record in client.daily_sleep.stream(start_date="2024-01-01"): + ... print(f"Sleep score: {sleep_record.score}") + """ + return self._stream_documents( + self.get_daily_sleep_documents, + start_date=start_date, + end_date=end_date + ) diff --git a/oura_api_client/api/heartrate.py b/oura_api_client/api/heartrate.py index 1c74edb..bc16cdd 100644 --- a/oura_api_client/api/heartrate.py +++ b/oura_api_client/api/heartrate.py @@ -1,42 +1,46 @@ """Heart rate endpoint implementations.""" -from typing import Optional, Dict, Any, Union +from typing import Optional, Dict, Any, Union, Iterator +from datetime import date -from ..models.heartrate import HeartRateResponse +from .base import BaseRouter +from ..models.heartrate import HeartRateResponse, HeartRateSample -class HeartRateEndpoints: +class HeartRateEndpoints(BaseRouter): """Heart rate related API endpoints.""" - def __init__(self, client): - """Initialize with a reference to the main client. - - Args: - client: The OuraClient instance - """ - self.client = client - def get_heartrate( self, - start_date: Optional[str] = None, - end_date: Optional[str] = None, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + next_token: Optional[str] = None, return_model: bool = True, ) -> Union[Dict[str, Any], HeartRateResponse]: """Get heart rate data for a specified date range. Args: - start_date (str, optional): Start date in YYYY-MM-DD format - end_date (str, optional): End date in YYYY-MM-DD format - return_model (bool): Whether to return a parsed model or raw dict + start_date: Start date in YYYY-MM-DD format or date object + end_date: End date in YYYY-MM-DD format or date object + next_token: Token for pagination + return_model: Whether to return a parsed model or raw dict Returns: Union[Dict[str, Any], HeartRateResponse]: Heart rate data """ params = {} if start_date: - params["start_date"] = start_date + if isinstance(start_date, date): + params["start_date"] = start_date.isoformat() + else: + params["start_date"] = start_date if end_date: - params["end_date"] = end_date + if isinstance(end_date, date): + params["end_date"] = end_date.isoformat() + else: + params["end_date"] = end_date + if next_token: + params["next_token"] = next_token response = self.client._make_request( "/usercollection/heartrate", params=params @@ -46,3 +50,28 @@ def get_heartrate( return HeartRateResponse.from_dict(response) return response + + def stream( + self, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + ) -> Iterator[HeartRateSample]: + """ + Stream all Heart Rate data automatically handling pagination. + + Args: + start_date: Start date for the period. + end_date: End date for the period. + + Yields: + HeartRateSample: Individual heart rate data points. + + Example: + >>> for hr_sample in client.heartrate.stream(start_date="2024-01-01"): + ... print(f"Heart rate: {hr_sample.bpm} at {hr_sample.timestamp}") + """ + return self._stream_documents( + self.get_heartrate, + start_date=start_date, + end_date=end_date + ) diff --git a/oura_api_client/api/session.py b/oura_api_client/api/session.py index 1083b65..4194ed7 100644 --- a/oura_api_client/api/session.py +++ b/oura_api_client/api/session.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Optional, Union, Iterator from datetime import date # Using date for start_date and end_date # as per other endpoints from oura_api_client.api.base import BaseRouter @@ -46,3 +46,28 @@ def get_session_document(self, document_id: str) -> SessionModel: f"/usercollection/session/{document_id}" ) return SessionModel(**response) + + def stream( + self, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + ) -> Iterator[SessionModel]: + """ + Stream all session documents automatically handling pagination. + + Args: + start_date: Start date for the period. + end_date: End date for the period. + + Yields: + SessionModel: Individual session documents. + + Example: + >>> for session in client.session.stream(start_date="2024-01-01"): + ... print(f"Session type: {session.type}") + """ + return self._stream_documents( + self.get_session_documents, + start_date=start_date, + end_date=end_date + ) diff --git a/oura_api_client/utils/__init__.py b/oura_api_client/utils/__init__.py index 4382d17..9608e35 100644 --- a/oura_api_client/utils/__init__.py +++ b/oura_api_client/utils/__init__.py @@ -2,6 +2,7 @@ from .query_params import build_query_params, convert_date_to_string from .retry import RetryConfig, retry_with_backoff, should_retry, exponential_backoff +from .pagination import stream_paginated_data __all__ = [ "build_query_params", @@ -9,5 +10,6 @@ "RetryConfig", "retry_with_backoff", "should_retry", - "exponential_backoff" + "exponential_backoff", + "stream_paginated_data" ] diff --git a/oura_api_client/utils/pagination.py b/oura_api_client/utils/pagination.py new file mode 100644 index 0000000..bcb50ed --- /dev/null +++ b/oura_api_client/utils/pagination.py @@ -0,0 +1,58 @@ +"""Pagination utilities for streaming data from Oura API endpoints.""" + +from typing import Iterator, Callable, TypeVar, Any, Optional, Union +from datetime import date + +# Type variables for generic pagination +T = TypeVar('T') # For individual data items +ResponseType = TypeVar('ResponseType') # For response objects + + +def stream_paginated_data( + fetch_function: Callable[..., ResponseType], + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, + **kwargs: Any +) -> Iterator[T]: + """ + Stream all paginated data from an API endpoint automatically. + + This function handles the pagination logic by following next_token + until all data is retrieved, yielding individual items one by one. + + Args: + fetch_function: The endpoint method to call (e.g., get_daily_sleep_documents) + start_date: Optional start date for filtering + end_date: Optional end date for filtering + **kwargs: Additional parameters to pass to the fetch function + + Yields: + Individual data items from the API response + + Example: + >>> for sleep_record in stream_paginated_data( + ... client.daily_sleep.get_daily_sleep_documents, + ... start_date="2024-01-01" + ... ): + ... print(sleep_record.score) + """ + next_token = None + + while True: + # Call the fetch function with current pagination token + response = fetch_function( + start_date=start_date, + end_date=end_date, + next_token=next_token, + **kwargs + ) + + # Yield each individual item from the current page + for item in response.data: + yield item + + # Check if there are more pages + if not response.next_token: + break + + next_token = response.next_token diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..1c82392 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,266 @@ +"""Tests for pagination helpers functionality.""" + +import unittest +from unittest.mock import Mock, MagicMock +from datetime import date + +from oura_api_client.utils.pagination import stream_paginated_data +from oura_api_client.api.daily_sleep import DailySleep +from oura_api_client.api.daily_activity import DailyActivity +from oura_api_client.api.heartrate import HeartRateEndpoints +from oura_api_client.api.session import Session +from oura_api_client.models.daily_sleep import DailySleepResponse, DailySleepModel, SleepContributors +from oura_api_client.models.daily_activity import DailyActivityResponse, DailyActivityModel, ActivityContributors +from oura_api_client.models.heartrate import HeartRateResponse, HeartRateSample +from oura_api_client.models.session import SessionResponse, SessionModel + + +class MockResponse: + """Mock response class for testing pagination.""" + + def __init__(self, data, next_token=None): + self.data = data + self.next_token = next_token + + +class TestPaginationUtils(unittest.TestCase): + """Test the core pagination utility functions.""" + + def test_stream_paginated_data_single_page(self): + """Test pagination with a single page of results.""" + # Mock response with no next_token + mock_response = MockResponse(data=[1, 2, 3], next_token=None) + + # Mock fetch function + mock_fetch = Mock(return_value=mock_response) + + # Stream the data + results = list(stream_paginated_data(mock_fetch, start_date="2024-01-01")) + + # Verify results + self.assertEqual(results, [1, 2, 3]) + mock_fetch.assert_called_once_with( + start_date="2024-01-01", + end_date=None, + next_token=None + ) + + def test_stream_paginated_data_multiple_pages(self): + """Test pagination with multiple pages of results.""" + # Mock responses for multiple pages + page1 = MockResponse(data=[1, 2], next_token="token_page2") + page2 = MockResponse(data=[3, 4], next_token="token_page3") + page3 = MockResponse(data=[5], next_token=None) + + # Mock fetch function that returns different pages + mock_fetch = Mock(side_effect=[page1, page2, page3]) + + # Stream the data + results = list(stream_paginated_data( + mock_fetch, + start_date="2024-01-01", + end_date="2024-01-31" + )) + + # Verify results + self.assertEqual(results, [1, 2, 3, 4, 5]) + + # Verify the fetch function was called correctly for each page + expected_calls = [ + unittest.mock.call(start_date="2024-01-01", end_date="2024-01-31", next_token=None), + unittest.mock.call(start_date="2024-01-01", end_date="2024-01-31", next_token="token_page2"), + unittest.mock.call(start_date="2024-01-01", end_date="2024-01-31", next_token="token_page3") + ] + mock_fetch.assert_has_calls(expected_calls) + + def test_stream_paginated_data_with_kwargs(self): + """Test pagination with additional keyword arguments.""" + mock_response = MockResponse(data=[1, 2, 3], next_token=None) + mock_fetch = Mock(return_value=mock_response) + + results = list(stream_paginated_data( + mock_fetch, + start_date="2024-01-01", + custom_param="test_value" + )) + + self.assertEqual(results, [1, 2, 3]) + mock_fetch.assert_called_once_with( + start_date="2024-01-01", + end_date=None, + next_token=None, + custom_param="test_value" + ) + + +class TestEndpointStreamMethods(unittest.TestCase): + """Test the stream methods on endpoint classes.""" + + def setUp(self): + """Set up mock client for testing.""" + self.mock_client = Mock() + + def test_daily_sleep_stream(self): + """Test DailySleep stream method.""" + # Create endpoint instance + endpoint = DailySleep(self.mock_client) + + # Mock the get_daily_sleep_documents method + mock_data = [ + DailySleepModel( + id="1", + contributors=SleepContributors(), + day=date.today(), + timestamp="2024-01-01T00:00:00" + ), + DailySleepModel( + id="2", + contributors=SleepContributors(), + day=date.today(), + timestamp="2024-01-02T00:00:00" + ) + ] + mock_response = MockResponse(data=mock_data, next_token=None) + endpoint.get_daily_sleep_documents = Mock(return_value=mock_response) + + # Test streaming + results = list(endpoint.stream(start_date="2024-01-01")) + + # Verify results + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, "1") + self.assertEqual(results[1].id, "2") + + # Verify the method was called correctly + endpoint.get_daily_sleep_documents.assert_called_once_with( + start_date="2024-01-01", + end_date=None, + next_token=None + ) + + def test_daily_activity_stream(self): + """Test DailyActivity stream method.""" + endpoint = DailyActivity(self.mock_client) + + # Mock data + mock_data = [ + DailyActivityModel( + id="1", + day=date.today(), + timestamp="2024-01-01T00:00:00", + contributors=ActivityContributors() + ), + DailyActivityModel( + id="2", + day=date.today(), + timestamp="2024-01-02T00:00:00", + contributors=ActivityContributors() + ) + ] + mock_response = MockResponse(data=mock_data, next_token=None) + endpoint.get_daily_activity_documents = Mock(return_value=mock_response) + + # Test streaming + results = list(endpoint.stream(start_date="2024-01-01")) + + # Verify results + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, "1") + + def test_heartrate_stream(self): + """Test HeartRateEndpoints stream method.""" + endpoint = HeartRateEndpoints(self.mock_client) + + # Mock data + mock_data = [ + HeartRateSample(timestamp="2024-01-01T12:00:00", bpm=70, source="ring"), + HeartRateSample(timestamp="2024-01-01T12:01:00", bpm=72, source="ring") + ] + mock_response = MockResponse(data=mock_data, next_token=None) + endpoint.get_heartrate = Mock(return_value=mock_response) + + # Test streaming + results = list(endpoint.stream(start_date="2024-01-01")) + + # Verify results + self.assertEqual(len(results), 2) + self.assertEqual(results[0].bpm, 70) + self.assertEqual(results[1].bpm, 72) + + def test_session_stream(self): + """Test Session stream method.""" + endpoint = Session(self.mock_client) + + # Mock data with required fields + mock_data = [ + SessionModel( + id="1", + day=date.today(), + timestamp="2024-01-01T00:00:00", + start_datetime="2024-01-01T10:00:00", + end_datetime="2024-01-01T11:00:00", + type="workout" + ), + SessionModel( + id="2", + day=date.today(), + timestamp="2024-01-02T00:00:00", + start_datetime="2024-01-02T10:00:00", + end_datetime="2024-01-02T11:00:00", + type="workout" + ) + ] + mock_response = MockResponse(data=mock_data, next_token=None) + endpoint.get_session_documents = Mock(return_value=mock_response) + + # Test streaming with date objects + results = list(endpoint.stream( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31) + )) + + # Verify results + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, "1") + + def test_stream_multiple_pages(self): + """Test streaming across multiple pages.""" + endpoint = DailySleep(self.mock_client) + + # Mock multiple pages + page1_data = [DailySleepModel( + id="1", + contributors=SleepContributors(), + day=date.today(), + timestamp="2024-01-01T00:00:00" + )] + page2_data = [DailySleepModel( + id="2", + contributors=SleepContributors(), + day=date.today(), + timestamp="2024-01-02T00:00:00" + )] + + page1 = MockResponse(data=page1_data, next_token="token2") + page2 = MockResponse(data=page2_data, next_token=None) + + endpoint.get_daily_sleep_documents = Mock(side_effect=[page1, page2]) + + # Test streaming + results = list(endpoint.stream(start_date="2024-01-01")) + + # Verify we got data from both pages + self.assertEqual(len(results), 2) + self.assertEqual(results[0].id, "1") + self.assertEqual(results[1].id, "2") + + # Verify correct pagination calls + expected_calls = [ + unittest.mock.call(start_date="2024-01-01", end_date=None, next_token=None), + unittest.mock.call(start_date="2024-01-01", end_date=None, next_token="token2") + ] + endpoint.get_daily_sleep_documents.assert_has_calls(expected_calls) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file