From b1d049bbdb0f2cc01fd27e089b9eaa079834f46e Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:52:47 +0100 Subject: [PATCH 1/9] Add json output --- .envrc | 1 + .gitignore | 3 +- AGENTS.md | 296 +++++++++++++++++++++++++++++++++++++++++++ fitbit_cli/cli.py | 12 +- fitbit_cli/main.py | 54 +------- fitbit_cli/output.py | 82 ++++++++++++ setup.py | 6 +- 7 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 .envrc create mode 100644 AGENTS.md create mode 100644 fitbit_cli/output.py diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..68b5a9f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +layout pyenv 3.14.2 diff --git a/.gitignore b/.gitignore index cbeb147..70ecc34 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,5 @@ 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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bedcf12 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,296 @@ +# AGENTS.md — fitbit-cli + +This file provides guidance for agentic coding agents working in this repository. + +--- + +## Project Overview + +`fitbit-cli` is a Python CLI 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.10+ +- **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.5.2") + 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; CONSOLE singleton + main.py # Entrypoint: wires CLI args → API calls → formatters +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 +``` + +--- + +## Environment Setup + +```bash +git clone git@github.com:veerendra2/fitbit-cli.git +cd fitbit-cli +python -m venv venv +source venv/bin/activate +pip install -e . +# Install dev/lint tools +pip install black isort pylint mypy pytest pytest-cov +``` + +--- + +## Build & Install + +```bash +# Editable install (development) +pip install -e . + +# Build distribution +pip install build +python -m build +``` + +--- + +## Running Tests + +### Run all tests + +```bash +pytest tests/ +``` + +### Run all tests with coverage + +```bash +pytest tests/ --cov=fitbit_cli +``` + +### Run a single test file + +```bash +pytest tests/cli_test.py +``` + +### Run a single test case by name + +```bash +pytest tests/cli_test.py::TestCLIDateFunctions::test_get_date_range +``` + +### Run tests using unittest directly + +```bash +python -m unittest discover -s tests -p "*_test.py" +# or a specific test: +python -m unittest tests.cli_test.TestCLIDateFunctions.test_get_date_range +``` + +**Test file naming convention:** `*_test.py` (not `test_*.py`). + +--- + +## Linting & Formatting + +All tools are configured in `pyproject.toml`. + +### Format code with black + +```bash +black fitbit_cli/ tests/ +``` + +### Sort imports with isort + +```bash +isort fitbit_cli/ tests/ +``` + +### Lint with pylint + +```bash +pylint fitbit_cli/ +``` + +### Type-check with mypy + +```bash +mypy fitbit_cli/ +``` + +### Run all checks (matches CI) + +```bash +black --check fitbit_cli/ tests/ +isort --check-only fitbit_cli/ tests/ +pylint fitbit_cli/ +mypy fitbit_cli/ +pytest tests/ --cov=fitbit_cli +``` + +**Tool settings:** +- `black`: `line-length = 88` +- `isort`: `profile = "black"` (compatible with black) +- `pylint`: `max-line-length = 120`; `E0401` (import errors) disabled globally +- `mypy`: `ignore_missing_imports = true` +- `flake8` and `ruff` are **not used** in this project + +--- + +## Code Style Guidelines + +### File Header + +Every source file begins with an encoding declaration and a module docstring: + +```python +# -*- coding: utf-8 -*- +""" +Module Description +""" +``` + +### Imports + +- Order: stdlib → third-party (`requests`, `rich`) → relative (`. import ...`) +- Managed by `isort` with `profile = "black"` +- Relative imports are used within the package: + +```python +from .exceptions import FitbitAPIError +from .fitbit_setup import update_fitbit_token +``` + +- Use `from . import module as alias` for module-level imports: + +```python +from . import fitbit_setup as setup +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` | +| Module-level 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. +- Format: `"""Short imperative description."""` — no blank line after the `def`. + +```python +def get_sleep_log(self, start_date, end_date=None): + """Get Sleep Logs by Date Range and Date""" + ... +``` + +### Type Annotations + +Type annotations are **not currently used** in source files. `mypy` is configured but set to `ignore_missing_imports = true`. Do not add annotations unless refactoring a file end-to-end for consistency. + +### String Formatting + +Use **f-strings** throughout. Do not use `%`-formatting or `.format()`. + +```python +url = f"https://api.fitbit.com/1/user/-/sleep/date/{date_range}.json" +raise FitbitAPIError(f"HTTP error occurred: {response.json()}") +``` + +### Error Handling + +- Custom exceptions live in `fitbit_cli/exceptions.py`. +- Both exception classes accept a single `message` arg and store it as `self.message`. +- Use specific exception types; avoid bare `except:` clauses. +- Preserve tracebacks with `raise ... from e`. +- HTTP 401 responses trigger an automatic token refresh inside `make_request()`. + +```python +class FitbitAPIError(Exception): + """Custom exception for Fitbit API""" + + def __init__(self, message): + super().__init__(message) + self.message = message +``` + +```python +except requests.exceptions.HTTPError as e: + if response.status_code == 401: + self.refresh_access_token() + ... + else: + raise FitbitAPIError(f"HTTP error occurred: {response.json()}") from e +``` + +### HTTP Requests + +All `requests` calls must include `timeout=5`: + +```python +response = requests.request(method, url, headers=self.headers, timeout=5, **kwargs) +``` + +### Rich Output + +All terminal output goes through `formatter.py`. The `CONSOLE` singleton is a module-level constant: + +```python +CONSOLE = Console() +``` + +Never print directly; use `CONSOLE.print(...)` or the `rich` table APIs. + +### pylint Inline Suppression + +Use inline directives sparingly and only when justified: + +```python +# pylint: disable=C0301 # line too long +# pylint: disable=C0413 # import not at top (test files adding sys.path) +# pylint: disable=C0103 # invalid variable name +``` + +--- + +## Testing Conventions + +- Framework: `unittest.TestCase` (tests are structured as unittest, run by pytest). +- Test files named `*_test.py` and placed in `tests/`. +- One test class per file, named `Test`. +- Each test method has a full docstring describing what it verifies. +- 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 + +Defined in `.github/workflows/`: + +- **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 setup 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/fitbit_cli/cli.py b/fitbit_cli/cli.py index 1e0e384..106b4b1 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -145,6 +145,13 @@ def parse_arguments(): help="Show Devices.", ) + parser.add_argument( + "-j", + "--raw-json", + action="store_true", + help="Output raw JSON from the Fitbit API (machine-readable, no tables or spinner).", + ) + parser.add_argument( "-v", "--version", @@ -155,7 +162,10 @@ 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 ("raw_json", "version") + } + if not any(data_args.values()): parser.error("No arguments provided. At least one argument is required.") return args diff --git a/fitbit_cli/main.py b/fitbit_cli/main.py index 201a977..a65e8b2 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 @@ -28,49 +26,7 @@ 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) + else: + output.table_display(fitbit, args) diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py new file mode 100644 index 0000000..776764a --- /dev/null +++ b/fitbit_cli/output.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +Output modes for the Fitbit CLI +""" + +import json +from datetime import 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(start_date) + data["date"] = str(start_date) + return [data] + start = datetime.strptime(start_date, "%Y-%m-%d") + end = datetime.strptime(end_date, "%Y-%m-%d") + 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 raw_json_display(fitbit, args): + """Collect 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)) + + +def table_display(fitbit, args): + """Fetch data and render rich tables to the terminal.""" + 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: + activity_data = collect_activities(fitbit, args) + unit_system = ( + fitbit.get_user_profile().get("user", "").get("distanceUnit", "METRIC") + ) + fmt.display_activity(activity_data, unit_system) diff --git a/setup.py b/setup.py index c6ad37e..e9bde79 100644 --- a/setup.py +++ b/setup.py @@ -34,14 +34,14 @@ 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.10", "Programming Language :: Python :: 3", "Programming Language :: Python", "Topic :: Utilities", @@ -50,6 +50,6 @@ "requests==2.32.5", "rich==14.3.3", ], - python_requires=">=3.9", + python_requires=">=3.10", entry_points={"console_scripts": ["fitbit-cli = fitbit_cli.main:main"]}, ) From a7f175c4bcf4cc9fb9386b2707851f19ebe605a5 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:07:34 +0100 Subject: [PATCH 2/9] Add minimal json output option --- .gitignore | 4 +- AGENTS.md | 311 +++++++++++++++++++++++++++++++++++++++- README.md | 9 +- fitbit_cli/__init__.py | 2 +- fitbit_cli/cli.py | 24 ++-- fitbit_cli/formatter.py | 180 ++++++++++++++++++++--- fitbit_cli/main.py | 2 + fitbit_cli/output.py | 44 ++++++ setup.py | 5 +- 9 files changed, 546 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 70ecc34..f69a95b 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,6 @@ cython_debug/ #.idea/ *.json -.direnv \ No newline at end of file +.direnv +.ruff_cache +.vscode \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index bedcf12..76324b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,320 @@ This file provides guidance for agentic coding agents working in this repository ## Project Overview -`fitbit-cli` is a Python CLI 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. +`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.10+ - **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 +``` + +--- + +## Environment Setup + +```bash +git clone git@github.com:veerendra2/fitbit-cli.git +cd fitbit-cli +python -m venv venv +source venv/bin/activate +pip install -e . +# Install dev/lint tools +pip install black isort pylint mypy pytest pytest-cov +``` + +--- + +## Build & Install + +```bash +# Editable install (development) +pip install -e . + +# Build distribution +pip install build +python -m build +``` + +--- + +## Running Tests + +### Run all tests + +```bash +pytest tests/ +``` + +### Run all tests with coverage + +```bash +pytest tests/ --cov=fitbit_cli +``` + +### Run a single test file + +```bash +pytest tests/cli_test.py +``` + +### Run a single test case by name + +```bash +pytest tests/cli_test.py::TestCLIDateFunctions::test_get_date_range +``` + +### Run tests using unittest directly + +```bash +python -m unittest discover -s tests -p "*_test.py" +# or a specific test: +python -m unittest tests.cli_test.TestCLIDateFunctions.test_get_date_range +``` + +**Test file naming convention:** `*_test.py` (not `test_*.py`). + +--- + +## Linting & Formatting + +All tools are configured in `pyproject.toml`. + +### Format code with black + +```bash +black fitbit_cli/ tests/ +``` + +### Sort imports with isort + +```bash +isort fitbit_cli/ tests/ +``` + +### Lint with pylint + +```bash +pylint fitbit_cli/ +``` + +### Type-check with mypy + +```bash +mypy fitbit_cli/ +``` + +### Run all checks (matches CI) + +```bash +black --check fitbit_cli/ tests/ +isort --check-only fitbit_cli/ tests/ +pylint fitbit_cli/ +mypy fitbit_cli/ +pytest tests/ --cov=fitbit_cli +``` + +**Tool settings:** +- `black`: `line-length = 88` +- `isort`: `profile = "black"` (compatible with black) +- `pylint`: `max-line-length = 120`; `E0401` (import errors) disabled globally +- `mypy`: `ignore_missing_imports = true` +- `flake8` and `ruff` are **not used** in this project + +--- + +## Code Style Guidelines + +### General Principles + +- Keep code **simple, short, and production-ready**. +- Write as a senior Python developer — readable, direct, no over-engineering. +- **Do not decompose into too many small functions** for the sake of it; favour readability over abstraction. +- **Do not change existing code** unless directly required by the task. + +### File Header + +Every source file begins with an encoding declaration and a module docstring: + +```python +# -*- coding: utf-8 -*- +""" +Module Description +""" +``` + +### Imports + +- Order: stdlib → third-party (`requests`, `rich`) → relative (`. import ...`) +- Managed by `isort` with `profile = "black"` +- Relative imports within the package: + +```python +from .exceptions import FitbitAPIError +from .fitbit_setup import update_fitbit_token +``` + +- Module-level imports use alias form: + +```python +from . import fitbit_setup as setup +from . import formatter as fmt +from . import output +``` + +### 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` | +| Module-level 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. +- Format: `"""Short imperative description."""` — no empty line after the `def`. + +```python +def get_sleep_log(self, start_date, end_date=None): + """Get Sleep Logs by Date Range and Date""" + ... +``` + +### Type Annotations + +Type annotations are **not currently used** in source files. `mypy` is configured but set to `ignore_missing_imports = true`. Do not add annotations unless refactoring a file end-to-end for consistency. + +### String Formatting + +Use **f-strings** throughout. Do not use `%`-formatting or `.format()`. + +```python +url = f"https://api.fitbit.com/1/user/-/sleep/date/{date_range}.json" +raise FitbitAPIError(f"HTTP error occurred: {response.json()}") +``` + +### Error Handling + +- Custom exceptions live in `fitbit_cli/exceptions.py`. +- Both exception classes accept a single `message` arg and store it as `self.message`. +- Use specific exception types; avoid bare `except:` clauses. +- Preserve tracebacks with `raise ... from e`. +- HTTP 401 responses trigger an automatic token refresh inside `make_request()`. + +### HTTP Requests + +All `requests` calls must include `timeout=5`: + +```python +response = requests.request(method, url, headers=self.headers, timeout=5, **kwargs) +``` + +### Output + +- Table output goes through `formatter.py` via `CONSOLE.print(...)`. Never call `print()` in table mode. +- JSON output uses plain `print(json.dumps(...))` — do **not** use `rich.print_json()` as it does not handle emojis in data. +- Each `display_*` function in `formatter.py` accepts `as_json=False`. When `True`, it returns a plain dict (no printing). `output.py` collects these dicts and prints once. + +### JSON Output Pattern (`formatter.py`) + +```python +def display_sleep(sleep_data, as_json=False): + """Sleep data formatter""" + if as_json: + return {"sleep": [...]} # plain dict, no print, no emojis in keys + # table branch unchanged + table = Table(...) + ... + CONSOLE.print(table) + return None +``` + +All `display_*` functions must have consistent return statements (both branches return explicitly) to satisfy pylint `R1710`. + +### pylint Inline Suppression + +Use inline directives sparingly and only when justified: + +```python +# pylint: disable=C0301 # line too long +# pylint: disable=C0413 # import not at top (test files adding sys.path) +# 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` | Minimized token-efficient JSON (table fields only) | +| `--raw-json` | `-r` | Full raw JSON response from Fitbit API | +| `--version` | `-v` | Show version | + +`--json` and `--raw-json` suppress the spinner and output to stdout — designed for AI agent use. + +--- + +## Testing Conventions + +- Framework: `unittest.TestCase` (tests are structured as unittest, run by pytest). +- Test files named `*_test.py` and placed in `tests/`. +- One test class per file, named `Test`. +- Each test method has a full docstring describing what it verifies. +- 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 + +Defined in `.github/workflows/`: + +- **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 setup 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`. + + --- ## Repository Layout @@ -190,7 +497,7 @@ from . import formatter as fmt ### Docstrings - All public classes, methods, and functions must have a one-line docstring. -- Format: `"""Short imperative description."""` — no blank line after the `def`. +- Format: `"""Short imperative description."""` — no empty line after the `def`. ```python def get_sleep_log(self, start_date, end_date=None): diff --git a/README.md b/README.md index d89fb2e..10e3751 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] [-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] [-j] [-r] [-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 pretty 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 106b4b1..f77a594 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 pretty 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" @@ -145,13 +159,6 @@ def parse_arguments(): help="Show Devices.", ) - parser.add_argument( - "-j", - "--raw-json", - action="store_true", - help="Output raw JSON from the Fitbit API (machine-readable, no tables or spinner).", - ) - parser.add_argument( "-v", "--version", @@ -163,8 +170,9 @@ def parse_arguments(): args = parser.parse_args() data_args = { - k: v for k, v in vars(args).items() if k not in ("raw_json", "version") + k: v for k, v in vars(args).items() if k not in ("json", "raw_json", "version") } + if not any(data_args.values()): parser.error("No arguments provided. At least one argument is required.") diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index 09c8137..b2bcaf5 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("") @@ -31,17 +48,40 @@ def display_user_profile(user_data): table.add_row(":hourglass_flowing_sand: Age", str(user["age"])) table.add_row(":restroom: Gender", user["gender"]) table.add_row(":straight_ruler: Height", f"{user['height']:.1f} {height_unit}") - table.add_row(":weight_lifter: Weight", f"{user['weight']:.1f} {weight_unit}") + table.add_row(":weight_lifter: Weight", f"{user['weight']:.1f} {weight_unit}") table.add_row(":footprints: Average Daily Steps", str(user["averageDailySteps"])) table.add_row(":calendar: Member Since", user["memberSince"]) 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,35 @@ 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')} {dis_unit}", + "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 +379,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 a65e8b2..44533cb 100644 --- a/fitbit_cli/main.py +++ b/fitbit_cli/main.py @@ -28,5 +28,7 @@ def main(): 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 index 776764a..574cb18 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -29,6 +29,50 @@ def collect_activities(fitbit, args): ] +def json_display(fitbit, args): + """Fetch data and render each requested endpoint as a single pretty JSON object to stdout.""" + result = {} + + if args.user_profile: + result.update(fmt.display_user_profile(fitbit.get_user_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) + unit_system = ( + fitbit.get_user_profile().get("user", "").get("distanceUnit", "METRIC") + ) + result.update(fmt.display_activity(activity_data, unit_system, as_json=True)) + + print(json.dumps(result, indent=2)) + + def raw_json_display(fitbit, args): """Collect API responses and print compact JSON to stdout.""" result = {} diff --git a/setup.py b/setup.py index e9bde79..622a927 100644 --- a/setup.py +++ b/setup.py @@ -41,8 +41,7 @@ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", "Programming Language :: Python", "Topic :: Utilities", ], @@ -50,6 +49,6 @@ "requests==2.32.5", "rich==14.3.3", ], - python_requires=">=3.10", + python_requires=">=3.12", entry_points={"console_scripts": ["fitbit-cli = fitbit_cli.main:main"]}, ) From 18d845d848aa151f3fc5adc02f96773d35e0ba71 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:13:39 +0100 Subject: [PATCH 3/9] Fix lints --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 76324b9..f726e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ pytest tests/ --cov=fitbit_cli ### General Principles - Keep code **simple, short, and production-ready**. -- Write as a senior Python developer — readable, direct, no over-engineering. +- Write as a senior Python developer — readable, direct, no overengineering. - **Do not decompose into too many small functions** for the sake of it; favour readability over abstraction. - **Do not change existing code** unless directly required by the task. From 6035b0cb8e63cef3d0e4455b9feebe7c78556e47 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:20:55 +0100 Subject: [PATCH 4/9] Bump requests to 2.33.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 622a927..846a6c3 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "Topic :: Utilities", ], install_requires=[ - "requests==2.32.5", + "requests==2.33.0", "rich==14.3.3", ], python_requires=">=3.12", From 818561030a25063be26e2816cca400b38f718910 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:37:29 +0100 Subject: [PATCH 5/9] Fix copilot issues --- AGENTS.md | 289 +--------------------------------------- fitbit_cli/formatter.py | 2 +- fitbit_cli/output.py | 28 ++-- 3 files changed, 22 insertions(+), 297 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f726e44..fcca333 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This file provides guidance for agentic coding agents working in this repository `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.10+ +- **Language:** Python 3.12+ - **Entry point:** `fitbit_cli/main.py` → `main()` - **CLI installed as:** `fitbit-cli` - **Token storage:** `~/.fitbit/token.json` @@ -283,294 +283,11 @@ Use inline directives sparingly and only when justified: | `--activities` | `-t` | Daily activity summary | | `--user-profile` | `-u` | User profile | | `--devices` | `-d` | Devices list | -| `--json` | `-j` | Minimized token-efficient JSON (table fields only) | +| `--json` | `-j` | Compact token-efficient JSON (table fields only) | | `--raw-json` | `-r` | Full raw JSON response from Fitbit API | | `--version` | `-v` | Show version | -`--json` and `--raw-json` suppress the spinner and output to stdout — designed for AI agent use. - ---- - -## Testing Conventions - -- Framework: `unittest.TestCase` (tests are structured as unittest, run by pytest). -- Test files named `*_test.py` and placed in `tests/`. -- One test class per file, named `Test`. -- Each test method has a full docstring describing what it verifies. -- 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 - -Defined in `.github/workflows/`: - -- **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 setup 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`. - - ---- - -## Repository Layout - -``` -fitbit_cli/ - __init__.py # Package version (__version__ = "1.5.2") - 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; CONSOLE singleton - main.py # Entrypoint: wires CLI args → API calls → formatters -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 -``` - ---- - -## Environment Setup - -```bash -git clone git@github.com:veerendra2/fitbit-cli.git -cd fitbit-cli -python -m venv venv -source venv/bin/activate -pip install -e . -# Install dev/lint tools -pip install black isort pylint mypy pytest pytest-cov -``` - ---- - -## Build & Install - -```bash -# Editable install (development) -pip install -e . - -# Build distribution -pip install build -python -m build -``` - ---- - -## Running Tests - -### Run all tests - -```bash -pytest tests/ -``` - -### Run all tests with coverage - -```bash -pytest tests/ --cov=fitbit_cli -``` - -### Run a single test file - -```bash -pytest tests/cli_test.py -``` - -### Run a single test case by name - -```bash -pytest tests/cli_test.py::TestCLIDateFunctions::test_get_date_range -``` - -### Run tests using unittest directly - -```bash -python -m unittest discover -s tests -p "*_test.py" -# or a specific test: -python -m unittest tests.cli_test.TestCLIDateFunctions.test_get_date_range -``` - -**Test file naming convention:** `*_test.py` (not `test_*.py`). - ---- - -## Linting & Formatting - -All tools are configured in `pyproject.toml`. - -### Format code with black - -```bash -black fitbit_cli/ tests/ -``` - -### Sort imports with isort - -```bash -isort fitbit_cli/ tests/ -``` - -### Lint with pylint - -```bash -pylint fitbit_cli/ -``` - -### Type-check with mypy - -```bash -mypy fitbit_cli/ -``` - -### Run all checks (matches CI) - -```bash -black --check fitbit_cli/ tests/ -isort --check-only fitbit_cli/ tests/ -pylint fitbit_cli/ -mypy fitbit_cli/ -pytest tests/ --cov=fitbit_cli -``` - -**Tool settings:** -- `black`: `line-length = 88` -- `isort`: `profile = "black"` (compatible with black) -- `pylint`: `max-line-length = 120`; `E0401` (import errors) disabled globally -- `mypy`: `ignore_missing_imports = true` -- `flake8` and `ruff` are **not used** in this project - ---- - -## Code Style Guidelines - -### File Header - -Every source file begins with an encoding declaration and a module docstring: - -```python -# -*- coding: utf-8 -*- -""" -Module Description -""" -``` - -### Imports - -- Order: stdlib → third-party (`requests`, `rich`) → relative (`. import ...`) -- Managed by `isort` with `profile = "black"` -- Relative imports are used within the package: - -```python -from .exceptions import FitbitAPIError -from .fitbit_setup import update_fitbit_token -``` - -- Use `from . import module as alias` for module-level imports: - -```python -from . import fitbit_setup as setup -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` | -| Module-level 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. -- Format: `"""Short imperative description."""` — no empty line after the `def`. - -```python -def get_sleep_log(self, start_date, end_date=None): - """Get Sleep Logs by Date Range and Date""" - ... -``` - -### Type Annotations - -Type annotations are **not currently used** in source files. `mypy` is configured but set to `ignore_missing_imports = true`. Do not add annotations unless refactoring a file end-to-end for consistency. - -### String Formatting - -Use **f-strings** throughout. Do not use `%`-formatting or `.format()`. - -```python -url = f"https://api.fitbit.com/1/user/-/sleep/date/{date_range}.json" -raise FitbitAPIError(f"HTTP error occurred: {response.json()}") -``` - -### Error Handling - -- Custom exceptions live in `fitbit_cli/exceptions.py`. -- Both exception classes accept a single `message` arg and store it as `self.message`. -- Use specific exception types; avoid bare `except:` clauses. -- Preserve tracebacks with `raise ... from e`. -- HTTP 401 responses trigger an automatic token refresh inside `make_request()`. - -```python -class FitbitAPIError(Exception): - """Custom exception for Fitbit API""" - - def __init__(self, message): - super().__init__(message) - self.message = message -``` - -```python -except requests.exceptions.HTTPError as e: - if response.status_code == 401: - self.refresh_access_token() - ... - else: - raise FitbitAPIError(f"HTTP error occurred: {response.json()}") from e -``` - -### HTTP Requests - -All `requests` calls must include `timeout=5`: - -```python -response = requests.request(method, url, headers=self.headers, timeout=5, **kwargs) -``` - -### Rich Output - -All terminal output goes through `formatter.py`. The `CONSOLE` singleton is a module-level constant: - -```python -CONSOLE = Console() -``` - -Never print directly; use `CONSOLE.print(...)` or the `rich` table APIs. - -### pylint Inline Suppression - -Use inline directives sparingly and only when justified: - -```python -# pylint: disable=C0301 # line too long -# pylint: disable=C0413 # import not at top (test files adding sys.path) -# pylint: disable=C0103 # invalid variable name -``` +`--json` and `--raw-json` suppress the spinner and output compact JSON to stdout — designed for AI agent use. --- diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index b2bcaf5..c4a05cd 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -48,7 +48,7 @@ def display_user_profile(user_data, as_json=False): table.add_row(":hourglass_flowing_sand: Age", str(user["age"])) table.add_row(":restroom: Gender", user["gender"]) table.add_row(":straight_ruler: Height", f"{user['height']:.1f} {height_unit}") - table.add_row(":weight_lifter: Weight", f"{user['weight']:.1f} {weight_unit}") + table.add_row(":weight_lifter: Weight", f"{user['weight']:.1f} {weight_unit}") table.add_row(":footprints: Average Daily Steps", str(user["averageDailySteps"])) table.add_row(":calendar: Member Since", user["memberSince"]) table.add_row(":clock1: Time Zone", user["timezone"]) diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 574cb18..280779f 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -4,7 +4,7 @@ """ import json -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from . import formatter as fmt @@ -13,11 +13,19 @@ 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(start_date) + data = fitbit.get_daily_activity_summary(str(start_date)) data["date"] = str(start_date) return [data] - start = datetime.strptime(start_date, "%Y-%m-%d") - end = datetime.strptime(end_date, "%Y-%m-%d") + 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( @@ -30,7 +38,7 @@ def collect_activities(fitbit, args): def json_display(fitbit, args): - """Fetch data and render each requested endpoint as a single pretty JSON object to stdout.""" + """Fetch data and render each requested endpoint as a single JSON object to stdout.""" result = {} if args.user_profile: @@ -66,15 +74,15 @@ def json_display(fitbit, args): if args.activities: activity_data = collect_activities(fitbit, args) unit_system = ( - fitbit.get_user_profile().get("user", "").get("distanceUnit", "METRIC") + fitbit.get_user_profile().get("user", {}).get("distanceUnit", "METRIC") ) result.update(fmt.display_activity(activity_data, unit_system, as_json=True)) - print(json.dumps(result, indent=2)) + print(json.dumps(result, separators=(",", ":"))) def raw_json_display(fitbit, args): - """Collect API responses and print compact JSON to stdout.""" + """Collect raw API responses and print compact JSON to stdout.""" result = {} if args.user_profile: @@ -96,7 +104,7 @@ def raw_json_display(fitbit, args): if args.activities: result["activities"] = collect_activities(fitbit, args) - print(json.dumps(result)) + print(json.dumps(result, separators=(",", ":"))) def table_display(fitbit, args): @@ -121,6 +129,6 @@ def table_display(fitbit, args): if args.activities: activity_data = collect_activities(fitbit, args) unit_system = ( - fitbit.get_user_profile().get("user", "").get("distanceUnit", "METRIC") + fitbit.get_user_profile().get("user", {}).get("distanceUnit", "METRIC") ) fmt.display_activity(activity_data, unit_system) From 14fe122544a741c6f14a3fe71e7c1e7d3c90935d Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:43:08 +0100 Subject: [PATCH 6/9] Update README --- AGENTS.md | 188 +++++++++------------------------------------- README.md | 6 +- fitbit_cli/cli.py | 2 +- 3 files changed, 38 insertions(+), 158 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcca333..939d9f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — fitbit-cli -This file provides guidance for agentic coding agents working in this repository. +Guidance for agentic coding agents working in this repository. --- @@ -35,131 +35,51 @@ setup.py # Package metadata and runtime dependencies --- -## Environment Setup +## Commands +### Setup ```bash -git clone git@github.com:veerendra2/fitbit-cli.git -cd fitbit-cli -python -m venv venv -source venv/bin/activate pip install -e . -# Install dev/lint tools pip install black isort pylint mypy pytest pytest-cov ``` ---- - -## Build & Install - -```bash -# Editable install (development) -pip install -e . - -# Build distribution -pip install build -python -m build -``` - ---- - -## Running Tests - -### Run all tests - -```bash -pytest tests/ -``` - -### Run all tests with coverage - -```bash -pytest tests/ --cov=fitbit_cli -``` - -### Run a single test file - -```bash -pytest tests/cli_test.py -``` - -### Run a single test case by name - +### Tests ```bash -pytest tests/cli_test.py::TestCLIDateFunctions::test_get_date_range -``` - -### Run tests using unittest directly - -```bash -python -m unittest discover -s tests -p "*_test.py" -# or a specific test: +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 - -All tools are configured in `pyproject.toml`. - -### Format code with black - +### Linting & formatting (run all before committing) ```bash black fitbit_cli/ tests/ -``` - -### Sort imports with isort - -```bash isort fitbit_cli/ tests/ -``` - -### Lint with pylint - -```bash pylint fitbit_cli/ -``` - -### Type-check with mypy - -```bash mypy fitbit_cli/ ``` -### Run all checks (matches CI) - +### CI check (read-only) ```bash black --check fitbit_cli/ tests/ isort --check-only fitbit_cli/ tests/ -pylint fitbit_cli/ -mypy fitbit_cli/ -pytest tests/ --cov=fitbit_cli ``` -**Tool settings:** -- `black`: `line-length = 88` -- `isort`: `profile = "black"` (compatible with black) -- `pylint`: `max-line-length = 120`; `E0401` (import errors) disabled globally -- `mypy`: `ignore_missing_imports = true` -- `flake8` and `ruff` are **not used** in this project +**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; favour readability over abstraction. +- 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 - -Every source file begins with an encoding declaration and a module docstring: - ```python # -*- coding: utf-8 -*- """ @@ -168,23 +88,9 @@ Module Description ``` ### Imports - -- Order: stdlib → third-party (`requests`, `rich`) → relative (`. import ...`) -- Managed by `isort` with `profile = "black"` -- Relative imports within the package: - -```python -from .exceptions import FitbitAPIError -from .fitbit_setup import update_fitbit_token -``` - -- Module-level imports use alias form: - -```python -from . import fitbit_setup as setup -from . import formatter as fmt -from . import output -``` +- 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 @@ -193,78 +99,55 @@ from . import output | Classes | `PascalCase` | `FitbitAPI`, `FitbitInitError` | | Functions / methods | `snake_case` | `get_sleep_log`, `parse_date_range` | | Private helpers | `_leading_underscore` | `_create_headers`, `_get_date_range` | -| Module-level constants | `UPPER_SNAKE_CASE` | `BASE_URL`, `TOKEN_URL`, `CONSOLE` | +| 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. -- Format: `"""Short imperative description."""` — no empty line after the `def`. - +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 - -Type annotations are **not currently used** in source files. `mypy` is configured but set to `ignore_missing_imports = true`. Do not add annotations unless refactoring a file end-to-end for consistency. +Not currently used. Do not add unless refactoring a file end-to-end. ### String Formatting - -Use **f-strings** throughout. Do not use `%`-formatting or `.format()`. - -```python -url = f"https://api.fitbit.com/1/user/-/sleep/date/{date_range}.json" -raise FitbitAPIError(f"HTTP error occurred: {response.json()}") -``` +Use f-strings throughout. Never use `%`-formatting or `.format()`. ### Error Handling - -- Custom exceptions live in `fitbit_cli/exceptions.py`. -- Both exception classes accept a single `message` arg and store it as `self.message`. -- Use specific exception types; avoid bare `except:` clauses. -- Preserve tracebacks with `raise ... from e`. -- HTTP 401 responses trigger an automatic token refresh inside `make_request()`. +- 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 - -All `requests` calls must include `timeout=5`: - +Always include `timeout=5`: ```python response = requests.request(method, url, headers=self.headers, timeout=5, **kwargs) ``` ### Output - -- Table output goes through `formatter.py` via `CONSOLE.print(...)`. Never call `print()` in table mode. -- JSON output uses plain `print(json.dumps(...))` — do **not** use `rich.print_json()` as it does not handle emojis in data. -- Each `display_*` function in `formatter.py` accepts `as_json=False`. When `True`, it returns a plain dict (no printing). `output.py` collects these dicts and prints once. - -### JSON Output Pattern (`formatter.py`) +- 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": [...]} # plain dict, no print, no emojis in keys - # table branch unchanged + return {"sleep": [...]} table = Table(...) - ... CONSOLE.print(table) return None ``` -All `display_*` functions must have consistent return statements (both branches return explicitly) to satisfy pylint `R1710`. - ### pylint Inline Suppression - -Use inline directives sparingly and only when justified: - +Use sparingly and only when justified: ```python # pylint: disable=C0301 # line too long -# pylint: disable=C0413 # import not at top (test files adding sys.path) +# pylint: disable=C0413 # import not at top # pylint: disable=C0103 # invalid variable name ``` @@ -283,7 +166,7 @@ Use inline directives sparingly and only when justified: | `--activities` | `-t` | Daily activity summary | | `--user-profile` | `-u` | User profile | | `--devices` | `-d` | Devices list | -| `--json` | `-j` | Compact token-efficient JSON (table fields only) | +| `--json` | `-j` | Output table data as JSON | | `--raw-json` | `-r` | Full raw JSON response from Fitbit API | | `--version` | `-v` | Show version | @@ -293,10 +176,9 @@ Use inline directives sparingly and only when justified: ## Testing Conventions -- Framework: `unittest.TestCase` (tests are structured as unittest, run by pytest). -- Test files named `*_test.py` and placed in `tests/`. +- Framework: `unittest.TestCase`, discovered and run by pytest. - One test class per file, named `Test`. -- Each test method has a full docstring describing what it verifies. +- 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. @@ -304,8 +186,6 @@ Use inline directives sparingly and only when justified: ## CI/CD -Defined in `.github/workflows/`: - - **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. @@ -314,7 +194,7 @@ Defined in `.github/workflows/`: ## Runtime Notes -- OAuth2 PKCE setup runs a temporary local server on `127.0.0.1:8080` to receive the auth code. +- 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 10e3751..18dce63 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,15 @@ 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] [-j] [-r] [-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 pretty JSON. + -j, --json Output table data as JSON. -r, --raw-json Output raw JSON from the Fitbit API. -v, --version Show fitbit-cli version diff --git a/fitbit_cli/cli.py b/fitbit_cli/cli.py index f77a594..3648494 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -76,7 +76,7 @@ def parse_arguments(): "-j", "--json", action="store_true", - help="Output table data as pretty JSON.", + help="Output table data as JSON.", ) parser.add_argument( From ef6b05ed1ecc5e99c34e72d4763b39bedcd77dd0 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:44:46 +0100 Subject: [PATCH 7/9] Remove .envrc --- .envrc | 1 - .gitignore | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 .envrc diff --git a/.envrc b/.envrc deleted file mode 100644 index 68b5a9f..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -layout pyenv 3.14.2 diff --git a/.gitignore b/.gitignore index f69a95b..6c3d323 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,5 @@ cython_debug/ *.json .direnv .ruff_cache -.vscode \ No newline at end of file +.vscode +.envrc \ No newline at end of file From 155eafb4757a9ad25823f0a38f0a0ad9dc368816 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:51:14 +0100 Subject: [PATCH 8/9] Fix copilot issues --- fitbit_cli/formatter.py | 6 +++++- fitbit_cli/output.py | 20 ++++++++++++-------- tests/cli_test.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/fitbit_cli/formatter.py b/fitbit_cli/formatter.py index c4a05cd..55560f4 100644 --- a/fitbit_cli/formatter.py +++ b/fitbit_cli/formatter.py @@ -338,7 +338,11 @@ def display_activity(activity_data, unit_system, as_json=False): "start_time": a.get("startTime"), "name": a.get("name"), "description": a.get("description"), - "distance": f"{a.get('distance')} {dis_unit}", + "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), diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 280779f..02b1bff 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -40,9 +40,11 @@ def collect_activities(fitbit, args): 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: - result.update(fmt.display_user_profile(fitbit.get_user_profile(), as_json=True)) + 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: @@ -73,9 +75,9 @@ def json_display(fitbit, args): ) if args.activities: activity_data = collect_activities(fitbit, args) - unit_system = ( - fitbit.get_user_profile().get("user", {}).get("distanceUnit", "METRIC") - ) + 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=(",", ":"))) @@ -110,8 +112,10 @@ def raw_json_display(fitbit, args): 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: - fmt.display_user_profile(fitbit.get_user_profile()) + profile = fitbit.get_user_profile() + fmt.display_user_profile(profile) if args.devices: fmt.display_devices(fitbit.get_devices()) if args.sleep: @@ -128,7 +132,7 @@ def table_display(fitbit, args): ) if args.activities: activity_data = collect_activities(fitbit, args) - unit_system = ( - fitbit.get_user_profile().get("user", {}).get("distanceUnit", "METRIC") - ) + 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/tests/cli_test.py b/tests/cli_test.py index f9721df..f731989 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,32 @@ 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) + if __name__ == "__main__": unittest.main() From 2157f24aac9b342ca1931c6faddd0056df458269 Mon Sep 17 00:00:00 2001 From: Veerendra <8393701+veerendra2@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:06:49 +0100 Subject: [PATCH 9/9] Fix copilot issues --- fitbit_cli/cli.py | 6 ++++-- fitbit_cli/main.py | 1 + fitbit_cli/output.py | 3 +-- tests/cli_test.py | 6 ++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/fitbit_cli/cli.py b/fitbit_cli/cli.py index 3648494..fd67f2e 100644 --- a/fitbit_cli/cli.py +++ b/fitbit_cli/cli.py @@ -170,10 +170,12 @@ def parse_arguments(): args = parser.parse_args() data_args = { - k: v for k, v in vars(args).items() if k not in ("json", "raw_json", "version") + k: v + for k, v in vars(args).items() + if k not in ("json", "raw_json", "init_auth", "version") } - if not any(data_args.values()): + 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/main.py b/fitbit_cli/main.py index 44533cb..04f08d4 100644 --- a/fitbit_cli/main.py +++ b/fitbit_cli/main.py @@ -16,6 +16,7 @@ def main(): if args.init_auth: setup.fitbit_init_setup() + return credentials = setup.read_fitbit_token() diff --git a/fitbit_cli/output.py b/fitbit_cli/output.py index 02b1bff..a94ae4b 100644 --- a/fitbit_cli/output.py +++ b/fitbit_cli/output.py @@ -14,8 +14,7 @@ def collect_activities(fitbit, args): start_date, end_date = args.activities if end_date is None: data = fitbit.get_daily_activity_summary(str(start_date)) - data["date"] = str(start_date) - return [data] + return [{**data, "date": str(start_date)}] start = ( start_date if isinstance(start_date, date) diff --git a/tests/cli_test.py b/tests/cli_test.py index f731989..4325108 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -179,6 +179,12 @@ def test_raw_json_with_data_flag_parses_successfully(self): 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()