diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9da13..4e80db1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-cov + pip install . pytest pytest-cov - name: Run tests with coverage run: | diff --git a/README.md b/README.md index 7053a67..d30b45f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Access your Fitbit data directly from your terminal 💻. View 💤 sleep logs, | [Get AZM Time Series by Interval](https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/get-azm-timeseries-by-interval/) | ✅ | | [Get Breathing Rate Summary by Interval](https://dev.fitbit.com/build/reference/web-api/breathing-rate/get-br-summary-by-interval/) | ✅ | | [Get Daily Activity Summary](https://dev.fitbit.com/build/reference/web-api/activity/get-daily-activity-summary/) | ✅ | +| [Get Body Time Series by Date Range](https://dev.fitbit.com/build/reference/web-api/body-timeseries/get-body-timeseries-by-date-range/) | ✅ | | [Get HRV Summary by Interval](https://dev.fitbit.com/build/reference/web-api/heartrate-variability/get-hrv-summary-by-interval/) | ✅ | ## Usage Guide @@ -46,7 +47,8 @@ python -m pip install fitbit-cli ```bash fitbit-cli -h usage: fitbit-cli [-h] [-i] [-j] [-r] [-s [DATE[,DATE]|RELATIVE]] [-o [DATE[,DATE]|RELATIVE]] [-e [DATE[,DATE]|RELATIVE]] [-a [DATE[,DATE]|RELATIVE]] - [-b [DATE[,DATE]|RELATIVE]] [-t [DATE[,DATE]|RELATIVE]] [-H [DATE[,DATE]|RELATIVE]] [-u] [-d] [-v] + [-b [DATE[,DATE]|RELATIVE]] [-H [DATE[,DATE]|RELATIVE]] [-B [DATE[,DATE]|RELATIVE]] + [-t [DATE[,DATE]|RELATIVE]] [-u] [-d] [-v] Fitbit CLI -- Access your Fitbit data at your terminal. @@ -72,10 +74,12 @@ APIs: Show AZM Time Series by Interval. -b, --breathing-rate [DATE[,DATE]|RELATIVE] Show Breathing Rate Summary by Interval. - -t, --activities [DATE[,DATE]|RELATIVE] - Show Daily Activity Summary. -H, --hrv [DATE[,DATE]|RELATIVE] Show HRV Summary by Interval. + -B, --body [DATE[,DATE]|RELATIVE] + Show Body Time Series for Weight, BMI, and Body Fat. + -t, --activities [DATE[,DATE]|RELATIVE] + Show Daily Activity Summary. -u, --user-profile Show Profile. -d, --devices Show Devices. ``` diff --git a/fitbit_cli/__init__.py b/fitbit_cli/__init__.py index aae3fc8..d3ddf65 100644 --- a/fitbit_cli/__init__.py +++ b/fitbit_cli/__init__.py @@ -3,4 +3,4 @@ fitbit_cli Module """ -__version__ = "1.7.0" +__version__ = "1.8.0" diff --git a/fitbit_cli/cli.py b/fitbit_cli/cli.py index ec19d53..16a7c20 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -146,6 +146,15 @@ def parse_arguments(): metavar="DATE[,DATE]|RELATIVE", help="Show HRV Summary by Interval.", ) + group.add_argument( + "-B", + "--body", + type=parse_date_range, + nargs="?", + const=(datetime.today().date(), None), + metavar="DATE[,DATE]|RELATIVE", + help="Show Body Time Series for Weight, BMI, and Body Fat.", + ) group.add_argument( "-t", "--activities", diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 11a9919..7f179b9 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -158,6 +158,14 @@ def get_hrv_summary(self, start_date, end_date=None): response = self.make_request("GET", url) return response.json() + def get_body_time_series(self, resource_path, start_date, end_date=None): + """Get Body Time Series by Interval and Date""" + + date_range = f"{start_date}/{end_date}" if end_date else f"{start_date}/1d" + url = f"https://api.fitbit.com/1/user/-/body/{resource_path}/date/{date_range}.json" + response = self.make_request("GET", url) + return response.json() + def get_daily_activity_summary(self, start_date): """Get Daily Activity Summary""" diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index 3281de1..b0c05f8 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -311,6 +311,58 @@ def display_hrv(hrv_data, as_json=False): return None +def _merge_body_data(body_data): + """Merge weight, BMI, and body fat time series by date.""" + + merged = {} + resource_map = { + "weight": "body-weight", + "bmi": "body-bmi", + "fat": "body-fat", + } + + for resource, response_key in resource_map.items(): + for item in body_data.get(resource, {}).get(response_key, []): + date = item.get("dateTime") + if date not in merged: + merged[date] = { + "date": date, + "weight": None, + "bmi": None, + "fat": None, + } + merged[date][resource] = item.get("value") + + return [merged[date] for date in sorted(merged)] + + +def display_body(body_data, as_json=False): + """Body time series formatter""" + + merged_body = _merge_body_data(body_data) + + if as_json: + return {"body": merged_body} + + table = Table(title="Body Time Series :balance_scale:", show_header=True) + + table.add_column("Date :calendar:") + table.add_column("Weight :weight_lifter:") + table.add_column("BMI :straight_ruler:") + table.add_column("Body Fat % :chart_with_upwards_trend:") + + for body in merged_body: + table.add_row( + str(body.get("date", "N/A")), + str(body.get("weight", "N/A")), + str(body.get("bmi", "N/A")), + str(body.get("fat", "N/A")), + ) + + CONSOLE.print(table) + return None + + def display_devices(devices, as_json=False): """Devices list formatter""" diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 135aaaf..d2dfed6 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -36,6 +36,16 @@ def collect_activities(fitbit, args): ] +def collect_body(fitbit, args): + """Fetch body time series for weight, BMI, and body fat.""" + start_date, end_date = args.body + return { + "weight": fitbit.get_body_time_series("weight", start_date, end_date), + "bmi": fitbit.get_body_time_series("bmi", start_date, end_date), + "fat": fitbit.get_body_time_series("fat", start_date, end_date), + } + + def json_display(fitbit, args): """Fetch data and render each requested endpoint as a single JSON object to stdout.""" result = {} @@ -74,6 +84,8 @@ def json_display(fitbit, args): ) if args.hrv: result.update(fmt.display_hrv(fitbit.get_hrv_summary(*args.hrv), as_json=True)) + if args.body: + result.update(fmt.display_body(collect_body(fitbit, args), as_json=True)) if args.activities: activity_data = collect_activities(fitbit, args) if profile is None: @@ -106,6 +118,8 @@ def raw_json_display(fitbit, args): ) if args.hrv: result["hrv"] = fitbit.get_hrv_summary(*args.hrv) + if args.body: + result["body"] = collect_body(fitbit, args) if args.activities: result["activities"] = collect_activities(fitbit, args) @@ -135,6 +149,8 @@ def table_display(fitbit, args): ) if args.hrv: fmt.display_hrv(fitbit.get_hrv_summary(*args.hrv)) + if args.body: + fmt.display_body(collect_body(fitbit, args)) if args.activities: activity_data = collect_activities(fitbit, args) if profile is None: diff --git a/tests/body_test.py b/tests/body_test.py new file mode 100644 index 0000000..1e28561 --- /dev/null +++ b/tests/body_test.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +""" +Body CLI Tests +""" + +import os +import sys +import unittest +from argparse import Namespace +from unittest.mock import MagicMock, patch + +# 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 import formatter as fmt +from fitbit_cli import output +from fitbit_cli.cli import parse_arguments +from fitbit_cli.fitbit_api import FitbitAPI + + +class TestBodyFeature(unittest.TestCase): + """Test suite for the unified body time series CLI feature.""" + + @patch("sys.argv", ["fitbit-cli", "-B"]) + def test_body_short_flag_parses_successfully(self): + """Test that -B parses without error and provides a body date argument.""" + args = parse_arguments() + self.assertIsNotNone(args.body) + + @patch("sys.argv", ["fitbit-cli", "--json", "--body"]) + def test_body_with_json_flag_parses_successfully(self): + """Test that --body combined with --json parses without error.""" + args = parse_arguments() + self.assertTrue(args.json) + self.assertIsNotNone(args.body) + + @patch("sys.argv", ["fitbit-cli", "-b"]) + def test_breathing_rate_short_flag_still_parses_successfully(self): + """Test that -b remains available for breathing rate after moving body to -B.""" + args = parse_arguments() + self.assertIsNotNone(args.breathing_rate) + + @patch("sys.argv", ["fitbit-cli", "--breathing-rate"]) + def test_breathing_rate_long_flag_still_parses_successfully(self): + """Test that --breathing-rate remains available after moving body to -B.""" + args = parse_arguments() + self.assertIsNotNone(args.breathing_rate) + + def test_get_body_time_series_single_date_uses_period_endpoint(self): + """Test that a single body date uses the 1d body time series endpoint.""" + fitbit = FitbitAPI("client", "secret", "access", "refresh") + fitbit.make_request = MagicMock(return_value=MagicMock(json=lambda: {})) + + fitbit.get_body_time_series("weight", "2024-01-05") + + fitbit.make_request.assert_called_once_with( + "GET", + "https://api.fitbit.com/1/user/-/body/weight/date/2024-01-05/1d.json", + ) + + def test_get_body_time_series_date_range_uses_range_endpoint(self): + """Test that a body date range uses the body time series date-range endpoint.""" + fitbit = FitbitAPI("client", "secret", "access", "refresh") + fitbit.make_request = MagicMock(return_value=MagicMock(json=lambda: {})) + + fitbit.get_body_time_series("bmi", "2024-01-01", "2024-01-07") + + fitbit.make_request.assert_called_once_with( + "GET", + "https://api.fitbit.com/1/user/-/body/bmi/date/2024-01-01/2024-01-07.json", + ) + + def test_display_body_as_json_merges_weight_bmi_and_fat_by_date(self): + """Test that body formatter merges weight, BMI, and fat values into one per-date JSON view.""" + body_data = { + "weight": { + "body-weight": [ + {"dateTime": "2024-01-01", "value": "80.1"}, + {"dateTime": "2024-01-03", "value": "79.8"}, + ] + }, + "bmi": { + "body-bmi": [ + {"dateTime": "2024-01-01", "value": "24.7"}, + {"dateTime": "2024-01-02", "value": "24.6"}, + ] + }, + "fat": { + "body-fat": [ + {"dateTime": "2024-01-02", "value": "18.1"}, + {"dateTime": "2024-01-03", "value": "18.0"}, + ] + }, + } + + result = fmt.display_body(body_data, as_json=True) + + self.assertEqual( + result, + { + "body": [ + { + "date": "2024-01-01", + "weight": "80.1", + "bmi": "24.7", + "fat": None, + }, + { + "date": "2024-01-02", + "weight": None, + "bmi": "24.6", + "fat": "18.1", + }, + { + "date": "2024-01-03", + "weight": "79.8", + "bmi": None, + "fat": "18.0", + }, + ] + }, + ) + + @patch("fitbit_cli.formatter.CONSOLE.print") + def test_display_body_table_renders_single_combined_table(self, mock_print): + """Test that body formatter renders one combined table with weight, BMI, and fat columns.""" + body_data = { + "weight": {"body-weight": [{"dateTime": "2024-01-01", "value": "80.1"}]}, + "bmi": {"body-bmi": [{"dateTime": "2024-01-01", "value": "24.7"}]}, + "fat": {"body-fat": [{"dateTime": "2024-01-01", "value": "18.1"}]}, + } + + fmt.display_body(body_data) + + table = mock_print.call_args[0][0] + self.assertEqual(table.title, "Body Time Series :balance_scale:") + self.assertEqual(len(table.rows), 1) + + def test_json_display_uses_unified_body_formatter(self): + """Test that json_display fetches body weight, BMI, and fat and returns one unified body payload.""" + fitbit = MagicMock() + fitbit.get_body_time_series.side_effect = [ + {"body-weight": [{"dateTime": "2024-01-01", "value": "80.1"}]}, + {"body-bmi": [{"dateTime": "2024-01-01", "value": "24.7"}]}, + {"body-fat": [{"dateTime": "2024-01-01", "value": "18.1"}]}, + ] + args = Namespace( + user_profile=False, + devices=False, + sleep=None, + spo2=None, + heart=None, + active_zone=None, + breathing_rate=None, + hrv=None, + body=("2024-01-01", None), + activities=None, + ) + + with patch("builtins.print") as mock_print: + output.json_display(fitbit, args) + + fitbit.get_body_time_series.assert_any_call("weight", "2024-01-01", None) + fitbit.get_body_time_series.assert_any_call("bmi", "2024-01-01", None) + fitbit.get_body_time_series.assert_any_call("fat", "2024-01-01", None) + mock_print.assert_called_once_with( + '{"body":[{"date":"2024-01-01","weight":"80.1","bmi":"24.7","fat":"18.1"}]}' + ) + + def test_raw_json_display_includes_body_weight_bmi_and_fat_responses(self): + """Test that raw_json_display includes the three raw body resource responses.""" + fitbit = MagicMock() + fitbit.get_body_time_series.side_effect = [ + {"body-weight": [{"dateTime": "2026-04-01", "value": "81.5"}]}, + {"body-bmi": [{"dateTime": "2026-04-01", "value": "24.7"}]}, + {"body-fat": [{"dateTime": "2026-04-01", "value": "19.2"}]}, + ] + args = Namespace( + user_profile=False, + devices=False, + sleep=None, + spo2=None, + heart=None, + active_zone=None, + breathing_rate=None, + hrv=None, + body=("2026-04-01", None), + activities=None, + ) + + with patch("builtins.print") as mock_print: + output.raw_json_display(fitbit, args) + + expected_json = ( + '{"body":{"weight":{"body-weight":[{"dateTime":"2026-04-01","value":"81.5"}]},' + '"bmi":{"body-bmi":[{"dateTime":"2026-04-01","value":"24.7"}]},' + '"fat":{"body-fat":[{"dateTime":"2026-04-01","value":"19.2"}]}}}' + ) + mock_print.assert_called_once_with(expected_json) + + @patch("fitbit_cli.output.fmt.display_body") + def test_table_display_uses_unified_body_formatter(self, mock_display_body): + """Test that table_display fetches unified body data and renders a single body table.""" + fitbit = MagicMock() + fitbit.get_body_time_series.side_effect = [ + {"body-weight": [{"dateTime": "2026-04-01", "value": "81.5"}]}, + {"body-bmi": [{"dateTime": "2026-04-01", "value": "24.7"}]}, + {"body-fat": [{"dateTime": "2026-04-01", "value": "19.2"}]}, + ] + args = Namespace( + user_profile=False, + devices=False, + sleep=None, + spo2=None, + heart=None, + active_zone=None, + breathing_rate=None, + hrv=None, + body=("2026-04-01", None), + activities=None, + ) + + output.table_display(fitbit, args) + + mock_display_body.assert_called_once_with( + { + "weight": { + "body-weight": [{"dateTime": "2026-04-01", "value": "81.5"}] + }, + "bmi": {"body-bmi": [{"dateTime": "2026-04-01", "value": "24.7"}]}, + "fat": {"body-fat": [{"dateTime": "2026-04-01", "value": "19.2"}]}, + } + ) + + +if __name__ == "__main__": + unittest.main()