diff --git a/.gitignore b/.gitignore index cbeb147..6c3d323 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -*.json \ No newline at end of file +*.json +.direnv +.ruff_cache +.vscode +.envrc \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..939d9f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# AGENTS.md — fitbit-cli + +Guidance for agentic coding agents working in this repository. + +--- + +## Project Overview + +`fitbit-cli` is a Python command-line tool for fetching and displaying personal health data from the Fitbit API. It uses OAuth2 PKCE for authentication, the `requests` library for HTTP calls, and `rich` for terminal output. + +- **Language:** Python 3.12+ +- **Entry point:** `fitbit_cli/main.py` → `main()` +- **CLI installed as:** `fitbit-cli` +- **Token storage:** `~/.fitbit/token.json` + +--- + +## Repository Layout + +``` +fitbit_cli/ + __init__.py # Package version (__version__ = "1.6.0") + cli.py # argparse setup; date parsing utilities + exceptions.py # FitbitInitError, FitbitAPIError + fitbit_api.py # FitbitAPI class wrapping all Fitbit REST endpoints + fitbit_setup.py # OAuth2 PKCE flow; token read/write/update + formatter.py # rich-based display functions + JSON extraction; CONSOLE singleton + output.py # Output modes: table_display, json_display, raw_json_display + main.py # Entrypoint: wires CLI args → API calls → output mode +tests/ + cli_test.py # unittest-based tests for date parsing logic +pyproject.toml # Build system + tool configuration (black, isort, pylint, mypy) +setup.py # Package metadata and runtime dependencies +``` + +--- + +## Commands + +### Setup +```bash +pip install -e . +pip install black isort pylint mypy pytest pytest-cov +``` + +### Tests +```bash +pytest tests/ # all tests +pytest tests/cli_test.py # single file +pytest tests/cli_test.py::TestCLIDateFunctions::test_get_date_range # single test +python -m unittest tests.cli_test.TestCLIDateFunctions.test_get_date_range +``` + +**Test file naming convention:** `*_test.py` (not `test_*.py`). + +### Linting & formatting (run all before committing) +```bash +black fitbit_cli/ tests/ +isort fitbit_cli/ tests/ +pylint fitbit_cli/ +mypy fitbit_cli/ +``` + +### CI check (read-only) +```bash +black --check fitbit_cli/ tests/ +isort --check-only fitbit_cli/ tests/ +``` + +**Tool settings:** `black` line-length 88; `isort` profile `black`; `pylint` max-line-length 120, `E0401` disabled; `mypy` `ignore_missing_imports = true`. `flake8` and `ruff` are **not used**. + +--- + +## Code Style Guidelines + +### General Principles +- Keep code **simple, short, and production-ready**. +- Write as a senior Python developer — readable, direct, no overengineering. +- Do not decompose into too many small functions for the sake of it. +- **Do not change existing code** unless directly required by the task. + +### File Header +```python +# -*- coding: utf-8 -*- +""" +Module Description +""" +``` + +### Imports +- Order: stdlib → third-party (`requests`, `rich`) → relative +- Relative symbol imports: `from .exceptions import FitbitAPIError` +- Module-level alias imports: `from . import formatter as fmt` + +### Naming Conventions + +| Kind | Convention | Example | +|------|------------|---------| +| Classes | `PascalCase` | `FitbitAPI`, `FitbitInitError` | +| Functions / methods | `snake_case` | `get_sleep_log`, `parse_date_range` | +| Private helpers | `_leading_underscore` | `_create_headers`, `_get_date_range` | +| Constants | `UPPER_SNAKE_CASE` | `BASE_URL`, `TOKEN_URL`, `CONSOLE` | +| Variables | `snake_case` | `start_date`, `access_token` | + +### Docstrings +All public classes, methods, and functions must have a one-line docstring. No empty line after `def`. +```python +def get_sleep_log(self, start_date, end_date=None): + """Get Sleep Logs by Date Range and Date""" +``` + +### Type Annotations +Not currently used. Do not add unless refactoring a file end-to-end. + +### String Formatting +Use f-strings throughout. Never use `%`-formatting or `.format()`. + +### Error Handling +- Custom exceptions: `FitbitInitError`, `FitbitAPIError` in `exceptions.py`. Both accept a single `message` arg. +- Use specific exception types; avoid bare `except:`. +- Preserve tracebacks: `raise ... from e`. +- HTTP 401 triggers automatic token refresh inside `make_request()`. + +### HTTP Requests +Always include `timeout=5`: +```python +response = requests.request(method, url, headers=self.headers, timeout=5, **kwargs) +``` + +### Output +- Table mode: always use `CONSOLE.print(...)`. Never call `print()` directly. +- JSON mode: use `print(json.dumps(..., separators=(",", ":")))` for compact output. Never use `rich.print_json()` — it breaks on emoji characters in data. +- Each `display_*` function in `formatter.py` accepts `as_json=False`. When `True`, returns a plain snake_case dict (no printing, no emoji keys). `output.py` collects dicts and prints once. +- Both branches of every `display_*` function must return explicitly (pylint `R1710`). + +```python +def display_sleep(sleep_data, as_json=False): + """Sleep data formatter""" + if as_json: + return {"sleep": [...]} + table = Table(...) + CONSOLE.print(table) + return None +``` + +### pylint Inline Suppression +Use sparingly and only when justified: +```python +# pylint: disable=C0301 # line too long +# pylint: disable=C0413 # import not at top +# pylint: disable=C0103 # invalid variable name +``` + +--- + +## CLI Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--init-auth` | `-i` | OAuth2 PKCE setup | +| `--sleep` | `-s` | Sleep log | +| `--spo2` | `-o` | SpO2 summary | +| `--heart` | `-e` | Heart rate time series | +| `--active-zone` | `-a` | Active zone minutes | +| `--breathing-rate` | `-b` | Breathing rate summary | +| `--activities` | `-t` | Daily activity summary | +| `--user-profile` | `-u` | User profile | +| `--devices` | `-d` | Devices list | +| `--json` | `-j` | Output table data as JSON | +| `--raw-json` | `-r` | Full raw JSON response from Fitbit API | +| `--version` | `-v` | Show version | + +`--json` and `--raw-json` suppress the spinner and output compact JSON to stdout — designed for AI agent use. + +--- + +## Testing Conventions + +- Framework: `unittest.TestCase`, discovered and run by pytest. +- One test class per file, named `Test`. +- Each test method has a full docstring. +- Use `unittest.mock.patch` to mock `datetime.today()` for deterministic date tests. +- Add `sys.path.insert(0, ...)` at the top of test files when needed to resolve imports. + +--- + +## CI/CD + +- **ci.yml**: Runs on PRs. Executes `super-linter` (black + isort + pylint; flake8/ruff disabled) then `pytest --cov` on Python 3.12. +- **release.yml**: Triggered on GitHub Release creation. Publishes to PyPI via `twine`. +- **dependabot.yml**: Weekly updates for `pip` and `github-actions` dependencies. + +--- + +## Runtime Notes + +- OAuth2 PKCE runs a temporary local server on `127.0.0.1:8080` to receive the auth code. +- Token file: `~/.fitbit/token.json` — contains `client_id`, `secret`, `access_token`, `refresh_token`. +- Tokens are valid for 8 hours and auto-refreshed on 401 responses. +- Only GET endpoints are implemented in `FitbitAPI`. diff --git a/README.md b/README.md index d89fb2e..18dce63 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ ](https://pypi.org/project/fitbit-cli/) [![ClickPy Stats](https://img.shields.io/badge/ClickPy%20Stats-A5951E) ](https://clickpy.clickhouse.com/dashboard/fitbit-cli) -> This is not an official Fitbit CLI +> _This is not an official Fitbit CLI_ Access your Fitbit data directly from your terminal 💻. View 💤 sleep logs, ❤️ heart rate, 🏋️‍♂️ activity levels, 🩸 SpO2, and more, all presented in a simple, easy-to-read table format! +> **AI agent-friendly** 🤖 — since v1.6.0, use `--json` for minimized, token-efficient JSON output or `--raw-json` for the full Fitbit API response. No spinners, pure JSON. +

Fitbit logo

@@ -42,13 +44,16 @@ python -m pip install fitbit-cli ```bash fitbit-cli -h -usage: fitbit-cli [-h] [-i] [-s [DATE[,DATE]|RELATIVE]] [-o [DATE[,DATE]|RELATIVE]] [-e [DATE[,DATE]|RELATIVE]] [-a [DATE[,DATE]|RELATIVE]] [-b [DATE[,DATE]|RELATIVE]] [-t [DATE[,DATE]|RELATIVE]] [-u] [-d] [-v] +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]] [-u] [-d] [-v] Fitbit CLI -- Access your Fitbit data at your terminal. options: -h, --help show this help message and exit -i, --init-auth Initialize Fitbit iterative authentication setup + -j, --json Output table data as JSON. + -r, --raw-json Output raw JSON from the Fitbit API. -v, --version Show fitbit-cli version APIs: diff --git a/fitbit_cli/__init__.py b/fitbit_cli/__init__.py index fbb3365..0c530e1 100644 --- a/fitbit_cli/__init__.py +++ b/fitbit_cli/__init__.py @@ -3,4 +3,4 @@ fitbit_cli Module """ -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/fitbit_cli/cli.py b/fitbit_cli/cli.py index 1e0e384..fd67f2e 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -72,6 +72,20 @@ def parse_arguments(): help="Initialize Fitbit iterative authentication setup", ) + parser.add_argument( + "-j", + "--json", + action="store_true", + help="Output table data as JSON.", + ) + + parser.add_argument( + "-r", + "--raw-json", + action="store_true", + help="Output raw JSON from the Fitbit API.", + ) + group = parser.add_argument_group( "APIs", "Specify a date, date range (YYYY-MM-DD[,YYYY-MM-DD]), or relative date.\n" @@ -155,7 +169,13 @@ def parse_arguments(): args = parser.parse_args() - if not any(vars(args).values()): + data_args = { + k: v + for k, v in vars(args).items() + if k not in ("json", "raw_json", "init_auth", "version") + } + + if not args.init_auth and not any(data_args.values()): parser.error("No arguments provided. At least one argument is required.") return args diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index 09c8137..55560f4 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -2,6 +2,7 @@ """ Json Data Formatter """ + from rich.console import Console from rich.table import Table from rich.text import Text @@ -9,7 +10,7 @@ CONSOLE = Console() -def display_user_profile(user_data): +def display_user_profile(user_data, as_json=False): """User data formatter""" user = user_data["user"] @@ -20,6 +21,22 @@ def display_user_profile(user_data): elif user["weightUnit"] == "US": weight_unit = "pounds" + if as_json: + return { + "user_profile": { + "first_name": user["firstName"], + "last_name": user["lastName"], + "date_of_birth": user["dateOfBirth"], + "age": user["age"], + "gender": user["gender"], + "height": f"{user['height']:.1f} {height_unit}", + "weight": f"{user['weight']:.1f} {weight_unit}", + "average_daily_steps": user["averageDailySteps"], + "member_since": user["memberSince"], + "timezone": user["timezone"], + } + } + table = Table(title=f"Hello, {user['displayName']} :wave:", show_header=False) table.add_column("") @@ -37,11 +54,34 @@ def display_user_profile(user_data): table.add_row(":clock1: Time Zone", user["timezone"]) CONSOLE.print(table) + return None -def display_sleep(sleep_data): +def display_sleep(sleep_data, as_json=False): """Sleep data formatter""" + if as_json: + return { + "sleep": [ + { + "date": s["dateOfSleep"], + "deep_minutes": s["levels"]["summary"] + .get("deep", {}) + .get("minutes"), + "light_minutes": s["levels"]["summary"] + .get("light", {}) + .get("minutes"), + "rem_minutes": s["levels"]["summary"].get("rem", {}).get("minutes"), + "wake_minutes": s["levels"]["summary"] + .get("wake", {}) + .get("minutes"), + "efficiency": s["efficiency"], + "time_in_bed_hours": round(s["timeInBed"] / 60, 1), + } + for s in sleep_data["sleep"] + ] + } + table = Table(title="Sleep Data Summary :sleeping:", show_header=True) table.add_column("Date :calendar:") @@ -64,11 +104,28 @@ def display_sleep(sleep_data): ) CONSOLE.print(table) + return None -def display_spo2(spo2_data): +def display_spo2(spo2_data, as_json=False): """SpO2 data formatter""" + if isinstance(spo2_data, dict): + spo2_data = [spo2_data] + + if as_json: + return { + "spo2": [ + { + "date": s.get("dateTime"), + "min": s.get("value", {}).get("min"), + "avg": s.get("value", {}).get("avg"), + "max": s.get("value", {}).get("max"), + } + for s in spo2_data + ] + } + table = Table(title="SpO2 Data Summary :heart:", show_header=True) table.add_column("Date :calendar:") @@ -76,9 +133,6 @@ def display_spo2(spo2_data): table.add_column("Average :blue_circle:") table.add_column("Maximum :green_circle:") - if isinstance(spo2_data, dict): - spo2_data = [spo2_data] - for spo2 in spo2_data: table.add_row( spo2.get("dateTime", "N/A"), @@ -88,11 +142,37 @@ def display_spo2(spo2_data): ) CONSOLE.print(table) + return None -def display_heart_data(heart_data): +def display_heart_data(heart_data, as_json=False): """Heart data formatter""" + if as_json: + return { + "heart": [ + { + "date": a.get("dateTime"), + "resting_heart_rate": a.get("value", {}).get("restingHeartRate"), + "zones": [ + { + "name": z.get("name"), + "min": z.get("min"), + "max": z.get("max"), + "minutes": z.get("minutes"), + "calories_out": ( + round(z["caloriesOut"], 2) + if isinstance(z.get("caloriesOut"), (int, float)) + else None + ), + } + for z in a.get("value", {}).get("heartRateZones", []) + ], + } + for a in heart_data.get("activities-heart", []) + ] + } + table = Table(title="Heart Rate Time Series :heart:", show_header=True) table.add_column("Date :calendar:") @@ -123,15 +203,30 @@ def display_heart_data(heart_data): else "N/A" ), ) - table.add_row(date, str(resting_heart_rate), zones_table) - CONSOLE.print(table) + return None -def display_azm_time_series(azm_data): +def display_azm_time_series(azm_data, as_json=False): """AZM Time Series data formatter""" + if as_json: + return { + "active_zone": [ + { + "date": a.get("dateTime"), + "active_zone_minutes": a.get("value", {}).get("activeZoneMinutes"), + "fat_burn_minutes": a.get("value", {}).get( + "fatBurnActiveZoneMinutes" + ), + "cardio_minutes": a.get("value", {}).get("cardioActiveZoneMinutes"), + "peak_minutes": a.get("value", {}).get("peakActiveZoneMinutes"), + } + for a in azm_data.get("activities-active-zone-minutes", []) + ] + } + table = Table(title="AZM Time Series :runner:", show_header=True) table.add_column("Date :calendar:") @@ -152,11 +247,23 @@ def display_azm_time_series(azm_data): ) CONSOLE.print(table) + return None -def display_breathing_rate(breathing_rate_data): +def display_breathing_rate(breathing_rate_data, as_json=False): """Breathing Rate data formatter""" + if as_json: + return { + "breathing_rate": [ + { + "date": br.get("dateTime"), + "breathing_rate": br.get("value", {}).get("breathingRate"), + } + for br in breathing_rate_data.get("br", []) + ] + } + table = Table(title="Breathing Rate Summary 🫁", show_header=True) table.add_column("Date :calendar:") @@ -169,9 +276,10 @@ def display_breathing_rate(breathing_rate_data): ) CONSOLE.print(table) + return None -def display_devices(devices): +def display_devices(devices, as_json=False): """Devices list formatter""" def format_mac(mac): @@ -179,6 +287,20 @@ def format_mac(mac): return mac return ":".join(mac[i : i + 2] for i in range(0, len(mac), 2)) + if as_json: + return { + "devices": [ + { + "battery_level": device.get("batteryLevel"), + "device": device.get("deviceVersion"), + "type": device.get("type"), + "last_sync_time": device.get("lastSyncTime"), + "mac_address": format_mac(str(device.get("mac", "N/A"))), + } + for device in devices + ] + } + table = Table(title="Devices List :link:", show_header=True) table.add_column("Battery % :battery:") @@ -190,7 +312,7 @@ def format_mac(mac): for device in devices: mac_address = format_mac(str(device.get("mac", "N/A"))) table.add_row( - f"{str(device.get("batteryLevel", "N/A"))}%", + f"{str(device.get('batteryLevel', 'N/A'))}%", str(device.get("deviceVersion", "N/A")), str(device.get("type", "N/A")), str(device.get("lastSyncTime", "N/A")), @@ -198,14 +320,39 @@ def format_mac(mac): ) CONSOLE.print(table) + return None -def display_activity(activity_data, unit_system): +def display_activity(activity_data, unit_system, as_json=False): """Activity data formatter""" - dis_unit = "km" - if unit_system == "US": - dis_unit = "miles" + dis_unit = "km" if unit_system != "US" else "miles" + + if as_json: + return { + "activities": [ + { + "date": day.get("date"), + "activities": [ + { + "start_time": a.get("startTime"), + "name": a.get("name"), + "description": a.get("description"), + "distance": ( + f"{a.get('distance', 'N/A')} {dis_unit}" + if a.get("distance") is not None + else None + ), + "steps": a.get("steps"), + "calories": a.get("calories"), + "duration_minutes": round(a.get("duration", 0) / 60000, 1), + } + for a in day.get("activities", []) + ], + } + for day in activity_data + ] + } table = Table(title="Daily Activities :runner:", show_header=True) @@ -236,3 +383,4 @@ def display_activity(activity_data, unit_system): table.add_row(activity_day.get("date", ""), activity_table) CONSOLE.print(table) + return None diff --git a/fitbit_cli/main.py b/fitbit_cli/main.py index 201a977..04f08d4 100644 --- a/fitbit_cli/main.py +++ b/fitbit_cli/main.py @@ -3,10 +3,8 @@ Main Module """ -from datetime import datetime, timedelta - from . import fitbit_setup as setup -from . import formatter as fmt +from . import output from .cli import parse_arguments from .fitbit_api import FitbitAPI @@ -18,6 +16,7 @@ def main(): if args.init_auth: setup.fitbit_init_setup() + return credentials = setup.read_fitbit_token() @@ -28,49 +27,9 @@ def main(): refresh_token=credentials["refresh_token"], ) - with fmt.CONSOLE.status("[bold green]Fetching data...") as _: - if args.user_profile: - fmt.display_user_profile(fitbit.get_user_profile()) - if args.devices: - fmt.display_devices(fitbit.get_devices()) - if args.sleep: - fmt.display_sleep(fitbit.get_sleep_log(*args.sleep)) - if args.spo2: - fmt.display_spo2(fitbit.get_spo2_summary(*args.spo2)) - if args.heart: - fmt.display_heart_data(fitbit.get_heart_rate_time_series(*args.heart)) - if args.active_zone: - fmt.display_azm_time_series(fitbit.get_azm_time_series(*args.active_zone)) - if args.breathing_rate: - fmt.display_breathing_rate( - fitbit.get_breathing_rate_summary(*args.breathing_rate) - ) - if args.activities: - start_date, end_date = args.activities - activity_data = [] - - # Get unit system from profile - unit_system = ( - fitbit.get_user_profile().get("user", "").get("distanceUnit", "METRIC") - ) - - # 'Get Daily Activity Summary' API only accepts single date - # So we either fetch one date or iterate through a range - if end_date is None: - data = fitbit.get_daily_activity_summary(start_date) - data["date"] = str(start_date) - activity_data = [data] - else: - start = datetime.strptime(start_date, "%Y-%m-%d") - end = datetime.strptime(end_date, "%Y-%m-%d") - activity_data = [ - { - **fitbit.get_daily_activity_summary( - (start + timedelta(days=i)).strftime("%Y-%m-%d") - ), - "date": (start + timedelta(days=i)).strftime("%Y-%m-%d"), - } - for i in range((end - start).days + 1) - ] - - fmt.display_activity(activity_data, unit_system) + if args.raw_json: + output.raw_json_display(fitbit, args) + elif args.json: + output.json_display(fitbit, args) + else: + output.table_display(fitbit, args) diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py new file mode 100644 index 0000000..a94ae4b --- /dev/null +++ b/fitbit_cli/output.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +Output modes for the Fitbit CLI +""" + +import json +from datetime import date, datetime, timedelta + +from . import formatter as fmt + + +def collect_activities(fitbit, args): + """Fetch activity data for a date or date range.""" + start_date, end_date = args.activities + if end_date is None: + data = fitbit.get_daily_activity_summary(str(start_date)) + return [{**data, "date": str(start_date)}] + start = ( + start_date + if isinstance(start_date, date) + else datetime.strptime(start_date, "%Y-%m-%d").date() + ) + end = ( + end_date + if isinstance(end_date, date) + else datetime.strptime(end_date, "%Y-%m-%d").date() + ) + return [ + { + **fitbit.get_daily_activity_summary( + (start + timedelta(days=i)).strftime("%Y-%m-%d") + ), + "date": (start + timedelta(days=i)).strftime("%Y-%m-%d"), + } + for i in range((end - start).days + 1) + ] + + +def json_display(fitbit, args): + """Fetch data and render each requested endpoint as a single JSON object to stdout.""" + result = {} + profile = None + + if args.user_profile: + profile = fitbit.get_user_profile() + result.update(fmt.display_user_profile(profile, as_json=True)) + if args.devices: + result.update(fmt.display_devices(fitbit.get_devices(), as_json=True)) + if args.sleep: + result.update( + fmt.display_sleep(fitbit.get_sleep_log(*args.sleep), as_json=True) + ) + if args.spo2: + result.update( + fmt.display_spo2(fitbit.get_spo2_summary(*args.spo2), as_json=True) + ) + if args.heart: + result.update( + fmt.display_heart_data( + fitbit.get_heart_rate_time_series(*args.heart), as_json=True + ) + ) + if args.active_zone: + result.update( + fmt.display_azm_time_series( + fitbit.get_azm_time_series(*args.active_zone), as_json=True + ) + ) + if args.breathing_rate: + result.update( + fmt.display_breathing_rate( + fitbit.get_breathing_rate_summary(*args.breathing_rate), as_json=True + ) + ) + if args.activities: + activity_data = collect_activities(fitbit, args) + if profile is None: + profile = fitbit.get_user_profile() + unit_system = profile.get("user", {}).get("distanceUnit", "METRIC") + result.update(fmt.display_activity(activity_data, unit_system, as_json=True)) + + print(json.dumps(result, separators=(",", ":"))) + + +def raw_json_display(fitbit, args): + """Collect raw API responses and print compact JSON to stdout.""" + result = {} + + if args.user_profile: + result["user_profile"] = fitbit.get_user_profile() + if args.devices: + result["devices"] = fitbit.get_devices() + if args.sleep: + result["sleep"] = fitbit.get_sleep_log(*args.sleep) + if args.spo2: + result["spo2"] = fitbit.get_spo2_summary(*args.spo2) + if args.heart: + result["heart"] = fitbit.get_heart_rate_time_series(*args.heart) + if args.active_zone: + result["active_zone"] = fitbit.get_azm_time_series(*args.active_zone) + if args.breathing_rate: + result["breathing_rate"] = fitbit.get_breathing_rate_summary( + *args.breathing_rate + ) + if args.activities: + result["activities"] = collect_activities(fitbit, args) + + print(json.dumps(result, separators=(",", ":"))) + + +def table_display(fitbit, args): + """Fetch data and render rich tables to the terminal.""" + with fmt.CONSOLE.status("[bold green]Fetching data...") as _: + profile = None + if args.user_profile: + profile = fitbit.get_user_profile() + fmt.display_user_profile(profile) + if args.devices: + fmt.display_devices(fitbit.get_devices()) + if args.sleep: + fmt.display_sleep(fitbit.get_sleep_log(*args.sleep)) + if args.spo2: + fmt.display_spo2(fitbit.get_spo2_summary(*args.spo2)) + if args.heart: + fmt.display_heart_data(fitbit.get_heart_rate_time_series(*args.heart)) + if args.active_zone: + fmt.display_azm_time_series(fitbit.get_azm_time_series(*args.active_zone)) + if args.breathing_rate: + fmt.display_breathing_rate( + fitbit.get_breathing_rate_summary(*args.breathing_rate) + ) + if args.activities: + activity_data = collect_activities(fitbit, args) + if profile is None: + profile = fitbit.get_user_profile() + unit_system = profile.get("user", {}).get("distanceUnit", "METRIC") + fmt.display_activity(activity_data, unit_system) diff --git a/setup.py b/setup.py index c6ad37e..846a6c3 100644 --- a/setup.py +++ b/setup.py @@ -34,22 +34,21 @@ classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: Apache Software License", + "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", "Programming Language :: Python", "Topic :: Utilities", ], install_requires=[ - "requests==2.32.5", + "requests==2.33.0", "rich==14.3.3", ], - python_requires=">=3.9", + python_requires=">=3.12", entry_points={"console_scripts": ["fitbit-cli = fitbit_cli.main:main"]}, ) diff --git a/tests/cli_test.py b/tests/cli_test.py index f9721df..4325108 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -13,7 +13,12 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__)))) # pylint: disable=C0413 -from fitbit_cli.cli import _get_date_range, _parse_relative_dates, parse_date_range +from fitbit_cli.cli import ( + _get_date_range, + _parse_relative_dates, + parse_arguments, + parse_date_range, +) class TestCLIDateFunctions(unittest.TestCase): @@ -148,6 +153,38 @@ def test_parse_date_range_invalid_format(self): with self.assertRaises(ValueError): parse_date_range("invalid-date-format") + @patch("sys.argv", ["fitbit-cli", "--json"]) + def test_json_flag_alone_raises_error(self): + """Test that --json alone (without a data flag) triggers the no-arguments error.""" + with self.assertRaises(SystemExit): + parse_arguments() + + @patch("sys.argv", ["fitbit-cli", "--raw-json"]) + def test_raw_json_flag_alone_raises_error(self): + """Test that --raw-json alone (without a data flag) triggers the no-arguments error.""" + with self.assertRaises(SystemExit): + parse_arguments() + + @patch("sys.argv", ["fitbit-cli", "--json", "--user-profile"]) + def test_json_with_data_flag_parses_successfully(self): + """Test that --json combined with a data flag parses without error.""" + args = parse_arguments() + self.assertTrue(args.json) + self.assertTrue(args.user_profile) + + @patch("sys.argv", ["fitbit-cli", "--raw-json", "--devices"]) + def test_raw_json_with_data_flag_parses_successfully(self): + """Test that --raw-json combined with a data flag parses without error.""" + args = parse_arguments() + self.assertTrue(args.raw_json) + self.assertTrue(args.devices) + + @patch("sys.argv", ["fitbit-cli", "--init-auth"]) + def test_init_auth_alone_parses_successfully(self): + """Test that --init-auth alone does not trigger the no-arguments error.""" + args = parse_arguments() + self.assertTrue(args.init_auth) + if __name__ == "__main__": unittest.main()