From b8747ea39a7cf9a51875eb7624b521a0582bc823 Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:14:22 +0000 Subject: [PATCH 1/6] Add weight and body fat support --- README.md | 13 ++- fitbit_cli/cli.py | 18 ++++ fitbit_cli/fitbit_api.py | 16 +++ fitbit_cli/formatter.py | 67 ++++++++++++ fitbit_cli/output.py | 30 ++++++ tests/body_metrics_test.py | 206 +++++++++++++++++++++++++++++++++++++ 6 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 tests/body_metrics_test.py diff --git a/README.md b/README.md index 7053a67..2ba1efa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ 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 Weight Log by Date / Date Range | ✅ | +| Get Body Fat Log by Date / 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 +48,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]] [-w [DATE[,DATE]|RELATIVE]] + [-f [DATE[,DATE]|RELATIVE]] [-t [DATE[,DATE]|RELATIVE]] [-u] [-d] [-v] Fitbit CLI -- Access your Fitbit data at your terminal. @@ -72,10 +75,14 @@ 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. + -w, --weight [DATE[,DATE]|RELATIVE] + Show Weight Log by Date Range. + -f, --body-fat [DATE[,DATE]|RELATIVE] + Show Body Fat Log by Date Range. + -t, --activities [DATE[,DATE]|RELATIVE] + Show Daily Activity Summary. -u, --user-profile Show Profile. -d, --devices Show Devices. ``` diff --git a/fitbit_cli/cli.py b/fitbit_cli/cli.py index ec19d53..b3758ce 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -146,6 +146,24 @@ def parse_arguments(): metavar="DATE[,DATE]|RELATIVE", help="Show HRV Summary by Interval.", ) + group.add_argument( + "-w", + "--weight", + type=parse_date_range, + nargs="?", + const=(datetime.today().date(), None), + metavar="DATE[,DATE]|RELATIVE", + help="Show Weight Log by Date Range.", + ) + group.add_argument( + "-f", + "--body-fat", + type=parse_date_range, + nargs="?", + const=(datetime.today().date(), None), + metavar="DATE[,DATE]|RELATIVE", + help="Show Body Fat Log by Date Range.", + ) group.add_argument( "-t", "--activities", diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 11a9919..9f18d4c 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -158,6 +158,22 @@ def get_hrv_summary(self, start_date, end_date=None): response = self.make_request("GET", url) return response.json() + def get_weight_log(self, start_date, end_date=None): + """Get Weight Log by Date Range and Date""" + + date_range = f"{start_date}/{end_date}" if end_date else start_date + url = f"https://api.fitbit.com/1/user/-/body/log/weight/date/{date_range}.json" + response = self.make_request("GET", url) + return response.json() + + def get_body_fat_log(self, start_date, end_date=None): + """Get Body Fat Log by Date Range and Date""" + + date_range = f"{start_date}/{end_date}" if end_date else start_date + url = f"https://api.fitbit.com/1/user/-/body/log/fat/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..5946fe4 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -311,6 +311,73 @@ def display_hrv(hrv_data, as_json=False): return None +def display_weight(weight_data, as_json=False): + """Weight data formatter""" + + if as_json: + return { + "weight": [ + { + "date": weight.get("date"), + "time": weight.get("time"), + "weight": weight.get("weight"), + "bmi": weight.get("bmi"), + } + for weight in weight_data.get("weight", []) + ] + } + + table = Table(title="Weight Log :balance_scale:", show_header=True) + + table.add_column("Date :calendar:") + table.add_column("Time :clock3:") + table.add_column("Weight :weight_lifter:") + table.add_column("BMI :straight_ruler:") + + for weight in weight_data.get("weight", []): + table.add_row( + str(weight.get("date", "N/A")), + str(weight.get("time", "N/A")), + str(weight.get("weight", "N/A")), + str(weight.get("bmi", "N/A")), + ) + + CONSOLE.print(table) + return None + + +def display_body_fat(body_fat_data, as_json=False): + """Body fat data formatter""" + + if as_json: + return { + "body_fat": [ + { + "date": body_fat.get("date"), + "time": body_fat.get("time"), + "fat": body_fat.get("fat"), + } + for body_fat in body_fat_data.get("fat", []) + ] + } + + table = Table(title="Body Fat Log :anatomical_heart:", show_header=True) + + table.add_column("Date :calendar:") + table.add_column("Time :clock3:") + table.add_column("Body Fat % :chart_with_upwards_trend:") + + for body_fat in body_fat_data.get("fat", []): + table.add_row( + str(body_fat.get("date", "N/A")), + str(body_fat.get("time", "N/A")), + str(body_fat.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..6641969 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -9,6 +9,16 @@ from . import formatter as fmt +def _remove_source(data, key): + """Remove source field from weight and body fat output.""" + return { + key: [ + {k: v for k, v in item.items() if k != "source"} + for item in data.get(key, []) + ] + } + + def collect_activities(fitbit, args): """Fetch activity data for a date or date range.""" start_date, end_date = args.activities @@ -74,6 +84,14 @@ def json_display(fitbit, args): ) if args.hrv: result.update(fmt.display_hrv(fitbit.get_hrv_summary(*args.hrv), as_json=True)) + if args.weight: + result.update( + fmt.display_weight(fitbit.get_weight_log(*args.weight), as_json=True) + ) + if args.body_fat: + result.update( + fmt.display_body_fat(fitbit.get_body_fat_log(*args.body_fat), as_json=True) + ) if args.activities: activity_data = collect_activities(fitbit, args) if profile is None: @@ -106,6 +124,14 @@ def raw_json_display(fitbit, args): ) if args.hrv: result["hrv"] = fitbit.get_hrv_summary(*args.hrv) + if args.weight: + result["weight"] = _remove_source( + fitbit.get_weight_log(*args.weight), "weight" + )["weight"] + if args.body_fat: + result["body_fat"] = _remove_source( + fitbit.get_body_fat_log(*args.body_fat), "fat" + )["fat"] if args.activities: result["activities"] = collect_activities(fitbit, args) @@ -135,6 +161,10 @@ def table_display(fitbit, args): ) if args.hrv: fmt.display_hrv(fitbit.get_hrv_summary(*args.hrv)) + if args.weight: + fmt.display_weight(fitbit.get_weight_log(*args.weight)) + if args.body_fat: + fmt.display_body_fat(fitbit.get_body_fat_log(*args.body_fat)) if args.activities: activity_data = collect_activities(fitbit, args) if profile is None: diff --git a/tests/body_metrics_test.py b/tests/body_metrics_test.py new file mode 100644 index 0000000..45beb03 --- /dev/null +++ b/tests/body_metrics_test.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +""" +Body Metrics Tests +""" + +import os +import sys +import unittest +from types import SimpleNamespace +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__)))) + +from fitbit_cli import formatter as fmt +from fitbit_cli import output + +# pylint: disable=C0413 +from fitbit_cli.cli import parse_arguments +from fitbit_cli.fitbit_api import FitbitAPI + + +class TestBodyMetrics(unittest.TestCase): + """Test suite for weight and body fat features.""" + + @patch("sys.argv", ["fitbit-cli", "--weight"]) + def test_weight_flag_parses_successfully(self): + """Test that --weight flag parses without error.""" + args = parse_arguments() + self.assertIsNotNone(args.weight) + + @patch("sys.argv", ["fitbit-cli", "--json", "--body-fat"]) + def test_body_fat_with_json_flag_parses_successfully(self): + """Test that --body-fat combined with --json parses without error.""" + args = parse_arguments() + self.assertTrue(args.json) + self.assertIsNotNone(args.body_fat) + + @patch.object(FitbitAPI, "make_request") + def test_get_weight_log_calls_expected_endpoint(self, mock_make_request): + """Test that get_weight_log calls the expected Fitbit endpoint for a single date.""" + mock_make_request.return_value.json.return_value = {"weight": []} + + fitbit = FitbitAPI("client", "secret", "access", "refresh") + fitbit.get_weight_log("2026-04-01") + + mock_make_request.assert_called_once_with( + "GET", + "https://api.fitbit.com/1/user/-/body/log/weight/date/2026-04-01.json", + ) + + @patch.object(FitbitAPI, "make_request") + def test_get_body_fat_log_calls_expected_endpoint_for_range( + self, mock_make_request + ): + """Test that get_body_fat_log calls the expected Fitbit endpoint for a date range.""" + mock_make_request.return_value.json.return_value = {"fat": []} + + fitbit = FitbitAPI("client", "secret", "access", "refresh") + fitbit.get_body_fat_log("2026-04-01", "2026-04-07") + + mock_make_request.assert_called_once_with( + "GET", + "https://api.fitbit.com/1/user/-/body/log/fat/date/2026-04-01/2026-04-07.json", + ) + + def test_display_weight_returns_expected_json(self): + """Test that display_weight returns normalized JSON output.""" + result = fmt.display_weight( + { + "weight": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "weight": 81.5, + "bmi": 24.7, + "source": "Aria", + } + ] + }, + as_json=True, + ) + + self.assertEqual( + result, + { + "weight": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "weight": 81.5, + "bmi": 24.7, + } + ] + }, + ) + + def test_display_body_fat_returns_expected_json(self): + """Test that display_body_fat returns normalized JSON output.""" + result = fmt.display_body_fat( + { + "fat": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "fat": 19.2, + "source": "Aria", + } + ] + }, + as_json=True, + ) + + self.assertEqual( + result, + { + "body_fat": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "fat": 19.2, + } + ] + }, + ) + + def test_json_display_includes_weight_and_body_fat(self): + """Test that json_display includes both weight and body fat results.""" + fitbit = MagicMock() + fitbit.get_weight_log.return_value = { + "weight": [ + {"date": "2026-04-01", "time": "07:15:00", "weight": 81.5, "bmi": 24.7} + ] + } + fitbit.get_body_fat_log.return_value = { + "fat": [{"date": "2026-04-01", "time": "07:15:00", "fat": 19.2}] + } + args = SimpleNamespace( + user_profile=False, + devices=False, + sleep=None, + spo2=None, + heart=None, + active_zone=None, + breathing_rate=None, + hrv=None, + activities=None, + weight=("2026-04-01", None), + body_fat=("2026-04-01", None), + ) + + with patch("builtins.print") as mock_print: + output.json_display(fitbit, args) + + mock_print.assert_called_once_with( + '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2}]}' + ) + + def test_raw_json_display_strips_source_from_weight_and_body_fat(self): + """Test that raw_json_display removes source from weight and body fat output.""" + fitbit = MagicMock() + fitbit.get_weight_log.return_value = { + "weight": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "weight": 81.5, + "bmi": 24.7, + "source": "Aria", + } + ] + } + fitbit.get_body_fat_log.return_value = { + "fat": [ + { + "date": "2026-04-01", + "time": "07:15:00", + "fat": 19.2, + "source": "Aria", + } + ] + } + args = SimpleNamespace( + user_profile=False, + devices=False, + sleep=None, + spo2=None, + heart=None, + active_zone=None, + breathing_rate=None, + hrv=None, + activities=None, + weight=("2026-04-01", None), + body_fat=("2026-04-01", None), + ) + + with patch("builtins.print") as mock_print: + output.raw_json_display(fitbit, args) + + mock_print.assert_called_once_with( + '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2}]}' + ) + + +if __name__ == "__main__": + unittest.main() From e5810e6a40dea0df25e704a933a839bf4ab4d9c3 Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:48:37 +0000 Subject: [PATCH 2/6] Add README links for weight and body fat APIs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ba1efa..7af20ca 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ 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 Weight Log by Date / Date Range | ✅ | -| Get Body Fat Log by Date / Date Range | ✅ | +| [Get Weight Log by Date](https://dev.fitbit.com/build/reference/web-api/body/get-weight-log-by-date/) | ✅ | +| [Get Body Fat Log by Date](https://dev.fitbit.com/build/reference/web-api/body/get-body-fat-log-by-date/) | ✅ | | [Get HRV Summary by Interval](https://dev.fitbit.com/build/reference/web-api/heartrate-variability/get-hrv-summary-by-interval/) | ✅ | ## Usage Guide From 36255aed713957937449612e2ac0e6a0d331caf4 Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:07:55 +0000 Subject: [PATCH 3/6] Fix Fitbit docs links for body API entries --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7af20ca..96ea87a 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ 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 Weight Log by Date](https://dev.fitbit.com/build/reference/web-api/body/get-weight-log-by-date/) | ✅ | -| [Get Body Fat Log by Date](https://dev.fitbit.com/build/reference/web-api/body/get-body-fat-log-by-date/) | ✅ | +| [Get Weight Log](https://dev.fitbit.com/build/reference/web-api/body/get-weight-log/) | ✅ | +| [Get Body Fat Log](https://dev.fitbit.com/build/reference/web-api/body/get-bodyfat-log/) | ✅ | | [Get HRV Summary by Interval](https://dev.fitbit.com/build/reference/web-api/heartrate-variability/get-hrv-summary-by-interval/) | ✅ | ## Usage Guide From 05f438527d8f8361ec8d080883081f16e065fe83 Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:33:58 +0000 Subject: [PATCH 4/6] Restore source in body metrics output --- fitbit_cli/formatter.py | 6 ++++++ fitbit_cli/output.py | 18 ++---------------- tests/body_metrics_test.py | 10 ++++++---- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index 5946fe4..5559200 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -322,6 +322,7 @@ def display_weight(weight_data, as_json=False): "time": weight.get("time"), "weight": weight.get("weight"), "bmi": weight.get("bmi"), + "source": weight.get("source"), } for weight in weight_data.get("weight", []) ] @@ -333,6 +334,7 @@ def display_weight(weight_data, as_json=False): table.add_column("Time :clock3:") table.add_column("Weight :weight_lifter:") table.add_column("BMI :straight_ruler:") + table.add_column("Source :satellite:") for weight in weight_data.get("weight", []): table.add_row( @@ -340,6 +342,7 @@ def display_weight(weight_data, as_json=False): str(weight.get("time", "N/A")), str(weight.get("weight", "N/A")), str(weight.get("bmi", "N/A")), + str(weight.get("source", "N/A")), ) CONSOLE.print(table) @@ -356,6 +359,7 @@ def display_body_fat(body_fat_data, as_json=False): "date": body_fat.get("date"), "time": body_fat.get("time"), "fat": body_fat.get("fat"), + "source": body_fat.get("source"), } for body_fat in body_fat_data.get("fat", []) ] @@ -366,12 +370,14 @@ def display_body_fat(body_fat_data, as_json=False): table.add_column("Date :calendar:") table.add_column("Time :clock3:") table.add_column("Body Fat % :chart_with_upwards_trend:") + table.add_column("Source :satellite:") for body_fat in body_fat_data.get("fat", []): table.add_row( str(body_fat.get("date", "N/A")), str(body_fat.get("time", "N/A")), str(body_fat.get("fat", "N/A")), + str(body_fat.get("source", "N/A")), ) CONSOLE.print(table) diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 6641969..837c15d 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -9,16 +9,6 @@ from . import formatter as fmt -def _remove_source(data, key): - """Remove source field from weight and body fat output.""" - return { - key: [ - {k: v for k, v in item.items() if k != "source"} - for item in data.get(key, []) - ] - } - - def collect_activities(fitbit, args): """Fetch activity data for a date or date range.""" start_date, end_date = args.activities @@ -125,13 +115,9 @@ def raw_json_display(fitbit, args): if args.hrv: result["hrv"] = fitbit.get_hrv_summary(*args.hrv) if args.weight: - result["weight"] = _remove_source( - fitbit.get_weight_log(*args.weight), "weight" - )["weight"] + result["weight"] = fitbit.get_weight_log(*args.weight) if args.body_fat: - result["body_fat"] = _remove_source( - fitbit.get_body_fat_log(*args.body_fat), "fat" - )["fat"] + result["body_fat"] = fitbit.get_body_fat_log(*args.body_fat) if args.activities: result["activities"] = collect_activities(fitbit, args) diff --git a/tests/body_metrics_test.py b/tests/body_metrics_test.py index 45beb03..ad3824d 100644 --- a/tests/body_metrics_test.py +++ b/tests/body_metrics_test.py @@ -90,6 +90,7 @@ def test_display_weight_returns_expected_json(self): "time": "07:15:00", "weight": 81.5, "bmi": 24.7, + "source": "Aria", } ] }, @@ -119,6 +120,7 @@ def test_display_body_fat_returns_expected_json(self): "date": "2026-04-01", "time": "07:15:00", "fat": 19.2, + "source": "Aria", } ] }, @@ -153,11 +155,11 @@ def test_json_display_includes_weight_and_body_fat(self): output.json_display(fitbit, args) mock_print.assert_called_once_with( - '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2}]}' + '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7,"source":null}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2,"source":null}]}' ) - def test_raw_json_display_strips_source_from_weight_and_body_fat(self): - """Test that raw_json_display removes source from weight and body fat output.""" + def test_raw_json_display_includes_source_for_weight_and_body_fat(self): + """Test that raw_json_display keeps source in weight and body fat output.""" fitbit = MagicMock() fitbit.get_weight_log.return_value = { "weight": [ @@ -198,7 +200,7 @@ def test_raw_json_display_strips_source_from_weight_and_body_fat(self): output.raw_json_display(fitbit, args) mock_print.assert_called_once_with( - '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2}]}' + '{"weight":{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7,"source":"Aria"}]},"body_fat":{"fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2,"source":"Aria"}]}}' ) From 48041d775dc88e8d05dab1f10f1e1a5fe6538c00 Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:11:50 +0000 Subject: [PATCH 5/6] feat: unify body metrics into one -B flag --- README.md | 13 +-- fitbit_cli/__init__.py | 2 +- fitbit_cli/cli.py | 15 +-- fitbit_cli/fitbit_api.py | 16 +-- fitbit_cli/formatter.py | 87 ++++++--------- fitbit_cli/output.py | 32 +++--- tests/body_metrics_test.py | 208 ----------------------------------- tests/body_test.py | 218 +++++++++++++++++++++++++++++++++++++ 8 files changed, 280 insertions(+), 311 deletions(-) delete mode 100644 tests/body_metrics_test.py create mode 100644 tests/body_test.py diff --git a/README.md b/README.md index 96ea87a..d30b45f 100644 --- a/README.md +++ b/README.md @@ -31,8 +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 Weight Log](https://dev.fitbit.com/build/reference/web-api/body/get-weight-log/) | ✅ | -| [Get Body Fat Log](https://dev.fitbit.com/build/reference/web-api/body/get-bodyfat-log/) | ✅ | +| [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 @@ -48,8 +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]] [-H [DATE[,DATE]|RELATIVE]] [-w [DATE[,DATE]|RELATIVE]] - [-f [DATE[,DATE]|RELATIVE]] [-t [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. @@ -77,10 +76,8 @@ APIs: Show Breathing Rate Summary by Interval. -H, --hrv [DATE[,DATE]|RELATIVE] Show HRV Summary by Interval. - -w, --weight [DATE[,DATE]|RELATIVE] - Show Weight Log by Date Range. - -f, --body-fat [DATE[,DATE]|RELATIVE] - Show Body Fat Log by Date Range. + -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. 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 b3758ce..16a7c20 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -147,22 +147,13 @@ def parse_arguments(): help="Show HRV Summary by Interval.", ) group.add_argument( - "-w", - "--weight", + "-B", + "--body", type=parse_date_range, nargs="?", const=(datetime.today().date(), None), metavar="DATE[,DATE]|RELATIVE", - help="Show Weight Log by Date Range.", - ) - group.add_argument( - "-f", - "--body-fat", - type=parse_date_range, - nargs="?", - const=(datetime.today().date(), None), - metavar="DATE[,DATE]|RELATIVE", - help="Show Body Fat Log by Date Range.", + help="Show Body Time Series for Weight, BMI, and Body Fat.", ) group.add_argument( "-t", diff --git a/fitbit_cli/fitbit_api.py b/fitbit_cli/fitbit_api.py index 9f18d4c..7f179b9 100644 --- a/fitbit_cli/fitbit_api.py +++ b/fitbit_cli/fitbit_api.py @@ -158,19 +158,11 @@ def get_hrv_summary(self, start_date, end_date=None): response = self.make_request("GET", url) return response.json() - def get_weight_log(self, start_date, end_date=None): - """Get Weight Log by Date Range and Date""" + 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 start_date - url = f"https://api.fitbit.com/1/user/-/body/log/weight/date/{date_range}.json" - response = self.make_request("GET", url) - return response.json() - - def get_body_fat_log(self, start_date, end_date=None): - """Get Body Fat Log by Date Range and Date""" - - date_range = f"{start_date}/{end_date}" if end_date else start_date - url = f"https://api.fitbit.com/1/user/-/body/log/fat/date/{date_range}.json" + 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() diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index 5559200..b0c05f8 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -311,73 +311,52 @@ def display_hrv(hrv_data, as_json=False): return None -def display_weight(weight_data, as_json=False): - """Weight data formatter""" - - if as_json: - return { - "weight": [ - { - "date": weight.get("date"), - "time": weight.get("time"), - "weight": weight.get("weight"), - "bmi": weight.get("bmi"), - "source": weight.get("source"), +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, } - for weight in weight_data.get("weight", []) - ] - } - - table = Table(title="Weight Log :balance_scale:", show_header=True) - - table.add_column("Date :calendar:") - table.add_column("Time :clock3:") - table.add_column("Weight :weight_lifter:") - table.add_column("BMI :straight_ruler:") - table.add_column("Source :satellite:") + merged[date][resource] = item.get("value") - for weight in weight_data.get("weight", []): - table.add_row( - str(weight.get("date", "N/A")), - str(weight.get("time", "N/A")), - str(weight.get("weight", "N/A")), - str(weight.get("bmi", "N/A")), - str(weight.get("source", "N/A")), - ) + return [merged[date] for date in sorted(merged)] - CONSOLE.print(table) - return None +def display_body(body_data, as_json=False): + """Body time series formatter""" -def display_body_fat(body_fat_data, as_json=False): - """Body fat data formatter""" + merged_body = _merge_body_data(body_data) if as_json: - return { - "body_fat": [ - { - "date": body_fat.get("date"), - "time": body_fat.get("time"), - "fat": body_fat.get("fat"), - "source": body_fat.get("source"), - } - for body_fat in body_fat_data.get("fat", []) - ] - } + return {"body": merged_body} - table = Table(title="Body Fat Log :anatomical_heart:", show_header=True) + table = Table(title="Body Time Series :balance_scale:", show_header=True) table.add_column("Date :calendar:") - table.add_column("Time :clock3:") + table.add_column("Weight :weight_lifter:") + table.add_column("BMI :straight_ruler:") table.add_column("Body Fat % :chart_with_upwards_trend:") - table.add_column("Source :satellite:") - for body_fat in body_fat_data.get("fat", []): + for body in merged_body: table.add_row( - str(body_fat.get("date", "N/A")), - str(body_fat.get("time", "N/A")), - str(body_fat.get("fat", "N/A")), - str(body_fat.get("source", "N/A")), + 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) diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 837c15d..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,14 +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.weight: - result.update( - fmt.display_weight(fitbit.get_weight_log(*args.weight), as_json=True) - ) - if args.body_fat: - result.update( - fmt.display_body_fat(fitbit.get_body_fat_log(*args.body_fat), 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: @@ -114,10 +118,8 @@ def raw_json_display(fitbit, args): ) if args.hrv: result["hrv"] = fitbit.get_hrv_summary(*args.hrv) - if args.weight: - result["weight"] = fitbit.get_weight_log(*args.weight) - if args.body_fat: - result["body_fat"] = fitbit.get_body_fat_log(*args.body_fat) + if args.body: + result["body"] = collect_body(fitbit, args) if args.activities: result["activities"] = collect_activities(fitbit, args) @@ -147,10 +149,8 @@ def table_display(fitbit, args): ) if args.hrv: fmt.display_hrv(fitbit.get_hrv_summary(*args.hrv)) - if args.weight: - fmt.display_weight(fitbit.get_weight_log(*args.weight)) - if args.body_fat: - fmt.display_body_fat(fitbit.get_body_fat_log(*args.body_fat)) + 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_metrics_test.py b/tests/body_metrics_test.py deleted file mode 100644 index ad3824d..0000000 --- a/tests/body_metrics_test.py +++ /dev/null @@ -1,208 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Body Metrics Tests -""" - -import os -import sys -import unittest -from types import SimpleNamespace -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__)))) - -from fitbit_cli import formatter as fmt -from fitbit_cli import output - -# pylint: disable=C0413 -from fitbit_cli.cli import parse_arguments -from fitbit_cli.fitbit_api import FitbitAPI - - -class TestBodyMetrics(unittest.TestCase): - """Test suite for weight and body fat features.""" - - @patch("sys.argv", ["fitbit-cli", "--weight"]) - def test_weight_flag_parses_successfully(self): - """Test that --weight flag parses without error.""" - args = parse_arguments() - self.assertIsNotNone(args.weight) - - @patch("sys.argv", ["fitbit-cli", "--json", "--body-fat"]) - def test_body_fat_with_json_flag_parses_successfully(self): - """Test that --body-fat combined with --json parses without error.""" - args = parse_arguments() - self.assertTrue(args.json) - self.assertIsNotNone(args.body_fat) - - @patch.object(FitbitAPI, "make_request") - def test_get_weight_log_calls_expected_endpoint(self, mock_make_request): - """Test that get_weight_log calls the expected Fitbit endpoint for a single date.""" - mock_make_request.return_value.json.return_value = {"weight": []} - - fitbit = FitbitAPI("client", "secret", "access", "refresh") - fitbit.get_weight_log("2026-04-01") - - mock_make_request.assert_called_once_with( - "GET", - "https://api.fitbit.com/1/user/-/body/log/weight/date/2026-04-01.json", - ) - - @patch.object(FitbitAPI, "make_request") - def test_get_body_fat_log_calls_expected_endpoint_for_range( - self, mock_make_request - ): - """Test that get_body_fat_log calls the expected Fitbit endpoint for a date range.""" - mock_make_request.return_value.json.return_value = {"fat": []} - - fitbit = FitbitAPI("client", "secret", "access", "refresh") - fitbit.get_body_fat_log("2026-04-01", "2026-04-07") - - mock_make_request.assert_called_once_with( - "GET", - "https://api.fitbit.com/1/user/-/body/log/fat/date/2026-04-01/2026-04-07.json", - ) - - def test_display_weight_returns_expected_json(self): - """Test that display_weight returns normalized JSON output.""" - result = fmt.display_weight( - { - "weight": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "weight": 81.5, - "bmi": 24.7, - "source": "Aria", - } - ] - }, - as_json=True, - ) - - self.assertEqual( - result, - { - "weight": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "weight": 81.5, - "bmi": 24.7, - "source": "Aria", - } - ] - }, - ) - - def test_display_body_fat_returns_expected_json(self): - """Test that display_body_fat returns normalized JSON output.""" - result = fmt.display_body_fat( - { - "fat": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "fat": 19.2, - "source": "Aria", - } - ] - }, - as_json=True, - ) - - self.assertEqual( - result, - { - "body_fat": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "fat": 19.2, - "source": "Aria", - } - ] - }, - ) - - def test_json_display_includes_weight_and_body_fat(self): - """Test that json_display includes both weight and body fat results.""" - fitbit = MagicMock() - fitbit.get_weight_log.return_value = { - "weight": [ - {"date": "2026-04-01", "time": "07:15:00", "weight": 81.5, "bmi": 24.7} - ] - } - fitbit.get_body_fat_log.return_value = { - "fat": [{"date": "2026-04-01", "time": "07:15:00", "fat": 19.2}] - } - args = SimpleNamespace( - user_profile=False, - devices=False, - sleep=None, - spo2=None, - heart=None, - active_zone=None, - breathing_rate=None, - hrv=None, - activities=None, - weight=("2026-04-01", None), - body_fat=("2026-04-01", None), - ) - - with patch("builtins.print") as mock_print: - output.json_display(fitbit, args) - - mock_print.assert_called_once_with( - '{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7,"source":null}],"body_fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2,"source":null}]}' - ) - - def test_raw_json_display_includes_source_for_weight_and_body_fat(self): - """Test that raw_json_display keeps source in weight and body fat output.""" - fitbit = MagicMock() - fitbit.get_weight_log.return_value = { - "weight": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "weight": 81.5, - "bmi": 24.7, - "source": "Aria", - } - ] - } - fitbit.get_body_fat_log.return_value = { - "fat": [ - { - "date": "2026-04-01", - "time": "07:15:00", - "fat": 19.2, - "source": "Aria", - } - ] - } - args = SimpleNamespace( - user_profile=False, - devices=False, - sleep=None, - spo2=None, - heart=None, - active_zone=None, - breathing_rate=None, - hrv=None, - activities=None, - weight=("2026-04-01", None), - body_fat=("2026-04-01", None), - ) - - with patch("builtins.print") as mock_print: - output.raw_json_display(fitbit, args) - - mock_print.assert_called_once_with( - '{"weight":{"weight":[{"date":"2026-04-01","time":"07:15:00","weight":81.5,"bmi":24.7,"source":"Aria"}]},"body_fat":{"fat":[{"date":"2026-04-01","time":"07:15:00","fat":19.2,"source":"Aria"}]}}' - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/body_test.py b/tests/body_test.py new file mode 100644 index 0000000..f0039c1 --- /dev/null +++ b/tests/body_test.py @@ -0,0 +1,218 @@ +# -*- 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.cli import parse_arguments +from fitbit_cli.fitbit_api import FitbitAPI +from fitbit_cli import formatter as fmt +from fitbit_cli import output + + +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) + + mock_print.assert_called_once_with( + '{"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"}]}}}' + ) + + @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() From 4f2042e8bbdcab590c24c905599a81b956cfb68e Mon Sep 17 00:00:00 2001 From: xraysight <20925002+xraysight@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:00:17 +0000 Subject: [PATCH 6/6] Failing pytest and lint tests addressed --- .github/workflows/ci.yml | 2 +- tests/body_test.py | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) 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/tests/body_test.py b/tests/body_test.py index f0039c1..1e28561 100644 --- a/tests/body_test.py +++ b/tests/body_test.py @@ -13,10 +13,10 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__)))) # pylint: disable=C0413 -from fitbit_cli.cli import parse_arguments -from fitbit_cli.fitbit_api import FitbitAPI 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): @@ -100,9 +100,24 @@ def test_display_body_as_json_merges_weight_bmi_and_fat_by_date(self): 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"}, + { + "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", + }, ] }, ) @@ -177,9 +192,12 @@ def test_raw_json_display_includes_body_weight_bmi_and_fat_responses(self): with patch("builtins.print") as mock_print: output.raw_json_display(fitbit, args) - mock_print.assert_called_once_with( - '{"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"}]}}}' + 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): @@ -207,7 +225,9 @@ def test_table_display_uses_unified_body_formatter(self, mock_display_body): mock_display_body.assert_called_once_with( { - "weight": {"body-weight": [{"dateTime": "2026-04-01", "value": "81.5"}]}, + "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"}]}, }