From 93addc9dd54b3c20683d6e95da7625a60441e6a2 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:56:38 +0000 Subject: [PATCH 1/3] fix: auto-chunk breathing rate and HRV date ranges > 30 days Fixes #35 --- fitbit_cli/fitbit_api.py | 57 ++++++++++++++++----- tests/date_range_test.py | 106 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 tests/date_range_test.py diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 7f179b9..c26fbd7 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -3,6 +3,8 @@ Fitbit API """ +from datetime import datetime, timedelta + import requests from .exceptions import FitbitAPIError @@ -134,29 +136,60 @@ def get_azm_intraday(self, start_date, end_date=None): response = self.make_request("GET", url) return response.json() + def _fetch_chunked_data( + self, url_template, key, start_date, end_date=None, max_days=30 + ): + """Helper to fetch and aggregate data for APIs with max date range limits.""" + if not end_date: + url = url_template.format(date_range=start_date) + return self.make_request("GET", url).json() + + start = ( + datetime.strptime(str(start_date), "%Y-%m-%d").date() + if isinstance(start_date, str) + else start_date + ) + end = ( + datetime.strptime(str(end_date), "%Y-%m-%d").date() + if isinstance(end_date, str) + else end_date + ) + + if (end - start).days < max_days: + url = url_template.format(date_range=f"{start_date}/{end_date}") + return self.make_request("GET", url).json() + + combined_items = [] + curr_start = start + while curr_start <= end: + curr_end = min(curr_start + timedelta(days=max_days - 1), end) + chunk_range = ( + f"{curr_start.strftime('%Y-%m-%d')}/{curr_end.strftime('%Y-%m-%d')}" + ) + url = url_template.format(date_range=chunk_range) + response = self.make_request("GET", url).json() + combined_items.extend(response.get(key, [])) + curr_start = curr_end + timedelta(days=1) + + return {key: combined_items} + def get_breathing_rate_summary(self, start_date, end_date=None): """Get Breathing Rate Summary by Interval and Data""" - date_range = f"{start_date}/{end_date}" if end_date else start_date - url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}.json" - response = self.make_request("GET", url) - return response.json() + url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}.json" + return self._fetch_chunked_data(url_template, "br", start_date, end_date) def get_breathing_rate_intraday(self, start_date, end_date=None): """Get Breathing Rate Intraday by Interval and Data""" - date_range = f"{start_date}/{end_date}" if end_date else start_date - url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json" - response = self.make_request("GET", url) - return response.json() + url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json" + return self._fetch_chunked_data(url_template, "br", start_date, end_date) def get_hrv_summary(self, start_date, end_date=None): """Get HRV Summary by Interval and Date""" - date_range = f"{start_date}/{end_date}" if end_date else start_date - url = f"https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json" - response = self.make_request("GET", url) - return response.json() + url_template = "https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json" + return self._fetch_chunked_data(url_template, "hrv", start_date, end_date) def get_body_time_series(self, resource_path, start_date, end_date=None): """Get Body Time Series by Interval and Date""" diff --git a/tests/date_range_test.py b/tests/date_range_test.py new file mode 100644 index 0000000..4490369 --- /dev/null +++ b/tests/date_range_test.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +""" +Date Range Chunking Tests for Breathing Rate and HRV APIs +""" + +import os +import sys +import unittest +from datetime import date +from unittest.mock import MagicMock + +# Add the parent directory to sys.path to make imports work +sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__)))) + +# pylint: disable=C0413 +from fitbit_cli.fitbit_api import FitbitAPI + + +class TestDateRangeChunking(unittest.TestCase): + """Test suite for auto-chunking date ranges > 30 days for Breathing Rate and HRV.""" + + def setUp(self): + self.fitbit = FitbitAPI("client", "secret", "access", "refresh") + + def test_breathing_rate_single_date(self): + """Test breathing rate summary with a single date.""" + mock_resp = MagicMock() + mock_resp.json.return_value = { + "br": [{"dateTime": "2026-01-01", "value": {"breathingRate": 14.0}}] + } + self.fitbit.make_request = MagicMock(return_value=mock_resp) + + res = self.fitbit.get_breathing_rate_summary("2026-01-01") + + self.fitbit.make_request.assert_called_once_with( + "GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01.json" + ) + self.assertEqual(len(res["br"]), 1) + + def test_breathing_rate_short_range(self): + """Test breathing rate summary with a range <= 30 days.""" + mock_resp = MagicMock() + mock_resp.json.return_value = { + "br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-15"}] + } + self.fitbit.make_request = MagicMock(return_value=mock_resp) + + res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-01-15") + + self.fitbit.make_request.assert_called_once_with( + "GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-15.json" + ) + self.assertEqual(len(res["br"]), 2) + + def test_breathing_rate_long_range_chunks_and_aggregates(self): + """Test breathing rate summary with a range > 30 days automatically chunks and merges data.""" + mock_resp1 = MagicMock() + mock_resp1.json.return_value = { + "br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-30"}] + } + + mock_resp2 = MagicMock() + mock_resp2.json.return_value = { + "br": [{"dateTime": "2026-01-31"}, {"dateTime": "2026-03-01"}] + } + + self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2]) + + # Range is 59 days: 2026-01-01 to 2026-03-01 + res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-03-01") + + expected_calls = [ + ( + ( + "GET", + "https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-30.json", + ), + ), + ( + ( + "GET", + "https://api.fitbit.com/1/user/-/br/date/2026-01-31/2026-03-01.json", + ), + ), + ] + self.assertEqual(self.fitbit.make_request.call_args_list, expected_calls) + self.assertEqual(len(res["br"]), 4) + + def test_hrv_long_range_chunks_and_aggregates(self): + """Test HRV summary with a range > 30 days automatically chunks and merges data.""" + mock_resp1 = MagicMock() + mock_resp1.json.return_value = {"hrv": [{"dateTime": "2026-01-01"}]} + + mock_resp2 = MagicMock() + mock_resp2.json.return_value = {"hrv": [{"dateTime": "2026-01-31"}]} + + self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2]) + + res = self.fitbit.get_hrv_summary(date(2026, 1, 1), date(2026, 3, 1)) + + self.assertEqual(self.fitbit.make_request.call_count, 2) + self.assertEqual(len(res["hrv"]), 2) + + +if __name__ == "__main__": + unittest.main() From 86d055aebda397d5ed51f0a0b8aeab1f25c203f6 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:01:18 +0000 Subject: [PATCH 2/3] style: reduce parameter count on _fetch_chunked_data to satisfy pylint R0913 --- fitbit_cli/fitbit_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index c26fbd7..818ccb2 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -136,10 +136,9 @@ def get_azm_intraday(self, start_date, end_date=None): response = self.make_request("GET", url) return response.json() - def _fetch_chunked_data( - self, url_template, key, start_date, end_date=None, max_days=30 - ): + def _fetch_chunked_data(self, url_template, key, start_date, end_date=None): """Helper to fetch and aggregate data for APIs with max date range limits.""" + max_days = 30 if not end_date: url = url_template.format(date_range=start_date) return self.make_request("GET", url).json() From 0a5b4d95984a1b5b8a7f58931b5ce378a6c31ce4 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:15:21 +0000 Subject: [PATCH 3/3] bump: version to 1.9.0 --- fitbit_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fitbit_cli/__init__.py b/fitbit_cli/__init__.py index d3ddf65..c7e642a 100644 --- a/fitbit_cli/__init__.py +++ b/fitbit_cli/__init__.py @@ -3,4 +3,4 @@ fitbit_cli Module """ -__version__ = "1.8.0" +__version__ = "1.9.0"