Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fitbit_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
fitbit_cli Module
"""

__version__ = "1.8.0"
__version__ = "1.9.0"
61 changes: 46 additions & 15 deletions fitbit_cli/fitbit_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Fitbit API
"""

import datetime

import requests

from .exceptions import FitbitAPIError
Expand Down Expand Up @@ -134,29 +136,58 @@ 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
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)

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"""

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"""
Expand Down
81 changes: 81 additions & 0 deletions tests/date_range_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# -*- 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(self.api.make_request.call_count, 2)
self.assertEqual(len(res["br"]), 4)


if __name__ == "__main__":
unittest.main()