From f4d4b4d874fb14d1783755b58a33af1af24162fb Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:20:47 +0000 Subject: [PATCH 1/2] feat: auto-chunk breathing rate and HRV date ranges > 30 days --- fitbit_cli/__init__.py | 2 +- fitbit_cli/fitbit_api.py | 57 ++++++++++++++++++++-------- tests/date_range_test.py | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 tests/date_range_test.py 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" diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 7f179b9..96eb09c 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -3,6 +3,8 @@ Fitbit API """ +import datetime + import requests from .exceptions import FitbitAPIError @@ -134,29 +136,54 @@ 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): + """Fetch data in chunks <= 30 days if date range > 30 days.""" + if not end_date: + url = url_template.format(date_range=start_date) + return self.make_request("GET", url).json() + + start = ( + datetime.date.fromisoformat(start_date) + if isinstance(start_date, str) + else start_date + ) + end = ( + datetime.date.fromisoformat(end_date) + if isinstance(end_date, str) + else end_date + ) + if (end - start).days <= 30: + url = url_template.format(date_range=f"{start_date}/{end_date}") + return self.make_request("GET", url).json() + + aggregated = [] + curr_start = start + while curr_start <= end: + curr_end = min(curr_start + datetime.timedelta(days=30), end) + url = url_template.format( + date_range=f"{curr_start.isoformat()}/{curr_end.isoformat()}" + ) + res = self.make_request("GET", url).json() + if key in res and isinstance(res[key], list): + aggregated.extend(res[key]) + curr_start = curr_end + datetime.timedelta(days=1) + + return {key: aggregated} + 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..d7c6df0 --- /dev/null +++ b/tests/date_range_test.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +""" +Tests for auto-chunking date ranges > 30 days. +""" + +import os +import sys +import unittest +from unittest.mock import MagicMock, call + +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 TestDateRangeAutoChunking(unittest.TestCase): + """Test suite for date range auto-chunking (>30 days).""" + + def setUp(self): + self.api = FitbitAPI("id", "secret", "access", "refresh") + self.api.make_request = MagicMock() + + def _mock_response(self, json_data): + mock_resp = MagicMock() + mock_resp.json.return_value = json_data + return mock_resp + + def test_single_date(self): + """Test single date request without end_date.""" + self.api.make_request.return_value = self._mock_response( + {"br": [{"dateTime": "2023-01-01", "value": {"fullDailyBpms": 15.0}}]} + ) + res = self.api.get_breathing_rate_summary("2023-01-01") + self.api.make_request.assert_called_once_with( + "GET", "https://api.fitbit.com/1/user/-/br/date/2023-01-01.json" + ) + self.assertEqual(len(res["br"]), 1) + + def test_range_under_30_days(self): + """Test range <= 30 days.""" + self.api.make_request.return_value = self._mock_response( + {"hrv": [{"dateTime": "2023-01-01"}, {"dateTime": "2023-01-30"}]} + ) + res = self.api.get_hrv_summary("2023-01-01", "2023-01-30") + self.api.make_request.assert_called_once_with( + "GET", + "https://api.fitbit.com/1/user/-/hrv/date/2023-01-01/2023-01-30.json", + ) + self.assertEqual(len(res["hrv"]), 2) + + def test_range_over_30_days_multi_chunk(self): + """Test range > 30 days resulting in multiple chunk requests.""" + # 2023-01-01 to 2023-02-15 = 45 days diff (chunk 1: Jan 1 to Jan 31 (30 days diff), chunk 2: Feb 1 to Feb 15) + resp1 = self._mock_response( + {"br": [{"dateTime": "2023-01-01"}, {"dateTime": "2023-01-31"}]} + ) + resp2 = self._mock_response( + {"br": [{"dateTime": "2023-02-01"}, {"dateTime": "2023-02-15"}]} + ) + self.api.make_request.side_effect = [resp1, resp2] + + res = self.api.get_breathing_rate_intraday("2023-01-01", "2023-02-15") + + expected_calls = [ + call( + "GET", + "https://api.fitbit.com/1/user/-/br/date/2023-01-01/2023-01-31/all.json", + ), + call( + "GET", + "https://api.fitbit.com/1/user/-/br/date/2023-02-01/2023-02-15/all.json", + ), + ] + self.api.make_request.assert_has_calls(expected_calls) + self.assertEqual(len(res["br"]), 4) + + +if __name__ == "__main__": + unittest.main() From 5229492dd18e4c8391618bfacce80f27b7bbc4e2 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:12:01 +0000 Subject: [PATCH 2/2] fix: address Copilot PR review suggestions for auto-chunk date range - Preserve top-level metadata fields from first response chunk in _fetch_chunked_data - Add exact call count assertion to multi-chunk date range test Co-Authored-By: Claude --- fitbit_cli/fitbit_api.py | 6 +++++- tests/date_range_test.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 96eb09c..1330ac0 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -158,17 +158,21 @@ def _fetch_chunked_data(self, url_template, key, start_date, end_date=None): aggregated = [] curr_start = start + result_dict = {} while curr_start <= end: curr_end = min(curr_start + datetime.timedelta(days=30), end) url = url_template.format( date_range=f"{curr_start.isoformat()}/{curr_end.isoformat()}" ) res = self.make_request("GET", url).json() + if not result_dict and isinstance(res, dict): + result_dict = res.copy() if key in res and isinstance(res[key], list): aggregated.extend(res[key]) curr_start = curr_end + datetime.timedelta(days=1) - return {key: aggregated} + result_dict[key] = aggregated + return result_dict def get_breathing_rate_summary(self, start_date, end_date=None): """Get Breathing Rate Summary by Interval and Data""" diff --git a/tests/date_range_test.py b/tests/date_range_test.py index d7c6df0..2f0d37b 100644 --- a/tests/date_range_test.py +++ b/tests/date_range_test.py @@ -73,6 +73,7 @@ def test_range_over_30_days_multi_chunk(self): ), ] self.api.make_request.assert_has_calls(expected_calls) + self.assertEqual(self.api.make_request.call_count, 2) self.assertEqual(len(res["br"]), 4)