diff --git a/google_health_api/cli/auth.py b/google_health_api/cli/auth.py new file mode 100644 index 0000000..1580d7f --- /dev/null +++ b/google_health_api/cli/auth.py @@ -0,0 +1,131 @@ +"""CLI authentication and session wrappers.""" + +import asyncio +import contextvars +import json +import os +from datetime import UTC, datetime +from typing import Any + +import aiohttp +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials + +from google_health_api.auth import AbstractAuth +from google_health_api.client import GoogleHealthSession +from google_health_api.const import HealthApiScope + +TOKEN_FILE = "token.json" +CLIENT_SECRET_FILE = "client_secret.json" +SCOPES = [ + HealthApiScope.ACTIVITY_READ, + HealthApiScope.ACTIVITY_WRITE, + HealthApiScope.MEASUREMENTS_READ, + HealthApiScope.MEASUREMENTS_WRITE, + HealthApiScope.PROFILE_READ, + HealthApiScope.PROFILE_WRITE, + HealthApiScope.SETTINGS_READ, + HealthApiScope.SETTINGS_WRITE, + HealthApiScope.SLEEP_READ, + HealthApiScope.SLEEP_WRITE, + HealthApiScope.NUTRITION_READ, + HealthApiScope.NUTRITION_WRITE, + HealthApiScope.LOCATION_READ, + HealthApiScope.ECG_READ, + HealthApiScope.IRN_READ, + HealthApiScope.USERINFO_PROFILE, + HealthApiScope.USERINFO_EMAIL, +] + +fields_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "fields", default=None +) + + +class CliHealthSession(GoogleHealthSession): + """Subclass of GoogleHealthSession to support dynamically injecting fields parameter.""" + + async def request( + self, + method: str, + url: str, + headers: dict[str, Any] | None = None, + **kwargs: Any, + ) -> aiohttp.ClientResponse: + fields = fields_var.get() + if fields: + params = kwargs.setdefault("params", {}) + params["fields"] = fields + return await super().request(method, url, headers=headers, **kwargs) + + +class CredentialsAuth(AbstractAuth): + """Auth wrapper that uses google-auth credentials.""" + + def __init__( + self, websession: aiohttp.ClientSession, credentials, host: str | None = None + ) -> None: + super().__init__(websession, host) + self._credentials = credentials + + async def async_get_access_token(self) -> str: + if not self._credentials.valid: + loop = asyncio.get_running_loop() + req = Request() + await loop.run_in_executor(None, self._credentials.refresh, req) + save_credentials(self._credentials) + return self._credentials.token + + +class EnvAuth(AbstractAuth): + """Auth wrapper that uses environment variable token directly (Agent DX).""" + + def __init__( + self, websession: aiohttp.ClientSession, token: str, host: str | None = None + ) -> None: + super().__init__(websession, host) + self._token = token + + async def async_get_access_token(self) -> str: + return self._token + + +def save_credentials(credentials) -> None: + """Save credentials to local token file.""" + with open(TOKEN_FILE, "w") as f: + f.write(credentials.to_json()) + + +def load_credentials_or_env(): + """Load credentials from environment or token.json.""" + token_env = os.environ.get("GOOGLE_HEALTH_CLI_TOKEN") + if token_env: + return ("env", token_env) + + if not os.path.exists(TOKEN_FILE): + return None + + with open(TOKEN_FILE, "r") as f: + data = json.load(f) + + expiry_str = data.get("expiry") + expiry = None + if expiry_str: + if expiry_str.endswith("Z"): + expiry = datetime.fromisoformat(expiry_str[:-1]) + else: + dt = datetime.fromisoformat(expiry_str) + if dt.tzinfo is not None: + dt = dt.astimezone(UTC).replace(tzinfo=None) + expiry = dt + + creds = Credentials( + token=data.get("token"), + refresh_token=data.get("refresh_token"), + token_uri=data.get("token_uri"), + client_id=data.get("client_id"), + client_secret=data.get("client_secret"), + scopes=data.get("scopes") or SCOPES, + expiry=expiry, + ) + return ("file", creds) diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index 6c6a048..38c1b3c 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -1,288 +1,156 @@ """Command implementations for Google Health CLI.""" -import asyncio -import contextvars -import json import os import sys from datetime import UTC, date, datetime, timedelta -from typing import Any, NoReturn from zoneinfo import ZoneInfo import aiohttp -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from google_auth_oauthlib.flow import Flow, InstalledAppFlow from google_health_api.api import GoogleHealthApi -from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession -from google_health_api.const import HEALTH_API_URL, HealthApiScope +from google_health_api.const import HEALTH_API_URL from google_health_api.exceptions import HealthApiException -from google_health_api.model import ( - DataPoint, - Profile, - ReconciledDataPoint, - Settings, - Subscriber, - SubscriberConfig, - Subscription, +from google_health_api.model import DataPoint + +from .auth import ( + CliHealthSession, + CredentialsAuth, + EnvAuth, + fields_var, + load_credentials_or_env, ) - -from .validation import check_dry_run, validate_resource_name - -TOKEN_FILE = "token.json" -CLIENT_SECRET_FILE = "client_secret.json" -SCOPES = [ - HealthApiScope.ACTIVITY_READ, - HealthApiScope.ACTIVITY_WRITE, - HealthApiScope.MEASUREMENTS_READ, - HealthApiScope.MEASUREMENTS_WRITE, - HealthApiScope.PROFILE_READ, - HealthApiScope.PROFILE_WRITE, - HealthApiScope.SETTINGS_READ, - HealthApiScope.SETTINGS_WRITE, - HealthApiScope.SLEEP_READ, - HealthApiScope.SLEEP_WRITE, - HealthApiScope.NUTRITION_READ, - HealthApiScope.NUTRITION_WRITE, - HealthApiScope.LOCATION_READ, - HealthApiScope.ECG_READ, - HealthApiScope.IRN_READ, - HealthApiScope.USERINFO_PROFILE, - HealthApiScope.USERINFO_EMAIL, -] - - -fields_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "fields", default=None +from .subcommands.devices import handle_devices_cmd +from .subcommands.identity import handle_identity_cmd, handle_irn_cmd +from .subcommands.login import cmd_login # noqa: F401 +from .subcommands.profile import handle_profile_cmd +from .subcommands.settings import handle_settings_cmd +from .subcommands.subscribers import handle_subscribers_cmd +from .subcommands.subscriptions import handle_subscriptions_cmd +from .subcommands.userinfo import handle_userinfo_cmd +from .utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_datapoint, + serialize_response, ) +from .validation import check_dry_run, validate_resource_name - -class CliHealthSession(GoogleHealthSession): - """Subclass of GoogleHealthSession to support dynamically injecting fields parameter.""" - - async def request( - self, - method: str, - url: str, - headers: dict[str, Any] | None = None, - **kwargs: Any, - ) -> aiohttp.ClientResponse: - fields = fields_var.get() - if fields: - params = kwargs.setdefault("params", {}) - params["fields"] = fields - return await super().request(method, url, headers=headers, **kwargs) - - -class CredentialsAuth(AbstractAuth): - """Auth wrapper that uses google-auth credentials.""" - - def __init__( - self, websession: aiohttp.ClientSession, credentials, host: str | None = None - ) -> None: - super().__init__(websession, host) - self._credentials = credentials - - async def async_get_access_token(self) -> str: - if not self._credentials.valid: - loop = asyncio.get_running_loop() - req = Request() - await loop.run_in_executor(None, self._credentials.refresh, req) - save_credentials(self._credentials) - return self._credentials.token - - -class EnvAuth(AbstractAuth): - """Auth wrapper that uses environment variable token directly (Agent DX).""" - - def __init__( - self, websession: aiohttp.ClientSession, token: str, host: str | None = None - ) -> None: - super().__init__(websession, host) - self._token = token - - async def async_get_access_token(self) -> str: - return self._token - - -def save_credentials(credentials) -> None: - """Save credentials to local token file.""" - with open(TOKEN_FILE, "w") as f: - f.write(credentials.to_json()) - - -def load_credentials_or_env(): - """Load credentials from environment or token.json.""" - token_env = os.environ.get("GOOGLE_HEALTH_CLI_TOKEN") - if token_env: - return ("env", token_env) - - if not os.path.exists(TOKEN_FILE): - return None - - with open(TOKEN_FILE, "r") as f: - data = json.load(f) - - expiry_str = data.get("expiry") - expiry = None - if expiry_str: - if expiry_str.endswith("Z"): - expiry = datetime.fromisoformat(expiry_str[:-1]) - else: - dt = datetime.fromisoformat(expiry_str) - if dt.tzinfo is not None: - dt = dt.astimezone(UTC).replace(tzinfo=None) - expiry = dt - - creds = Credentials( - token=data.get("token"), - refresh_token=data.get("refresh_token"), - token_uri=data.get("token_uri"), - client_id=data.get("client_id"), - client_secret=data.get("client_secret"), - scopes=data.get("scopes") or SCOPES, - expiry=expiry, - ) - return ("file", creds) - - -def print_json(data: Any, pretty: bool = True) -> None: - """Helper to output JSON data, respect pretty setting.""" - if pretty: - print(json.dumps(data, indent=2)) - else: - print(json.dumps(data)) - - -def print_error_json(message: str, status: str = "INTERNAL") -> NoReturn: - """Print standard JSON error and exit.""" - res = { - "error": { - "status": status, - "message": message, - } - } - print_json(res) - sys.exit(1) - - -def serialize_datapoint(dp: DataPoint, field_name: str) -> dict[str, Any]: - """Serialize generic DataPoint class to dictionary matching API payload structure.""" - res: dict[str, Any] = {} - if dp.name: - res["name"] = dp.name - if dp.data_source: - res["dataSource"] = dp.data_source.to_dict() - res[field_name] = dp.data.to_dict() - return res - - -def serialize_reconciled_datapoint( - rdp: ReconciledDataPoint, field_name: str -) -> dict[str, Any]: - """Serialize generic ReconciledDataPoint class.""" - return {"dataPoint": serialize_datapoint(rdp.data_point, field_name)} - - -def serialize_response(result: Any, field_name: str | None = None) -> Any: - """Serialize generic API result to JSON-compatible data structures.""" - if hasattr(result, "to_dict"): - return result.to_dict() - if hasattr(result, "data_points"): - assert field_name is not None - return { - "dataPoints": [ - serialize_datapoint(dp, field_name) for dp in result.data_points - ], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "reconciled_data_points"): - assert field_name is not None - return { - "reconciledDataPoints": [ - serialize_reconciled_datapoint(rdp, field_name) - for rdp in result.reconciled_data_points - ], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "paired_devices"): - return { - "pairedDevices": [dev.to_dict() for dev in result.paired_devices], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "subscribers"): - return { - "subscribers": [sub.to_dict() for sub in result.subscribers], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "subscriptions"): - return { - "subscriptions": [sub.to_dict() for sub in result.subscriptions], - "nextPageToken": result.next_page_token, - } - return result - - -def cmd_login(args) -> None: - """Execute interactive OAuth login flow.""" - if not os.path.exists(CLIENT_SECRET_FILE): - print_error_json( - f"Client secrets file '{CLIENT_SECRET_FILE}' not found.", - status="NOT_FOUND", - ) - - if not sys.stdin.isatty(): - print_error_json( - "Cannot run interactive login in a headless environment.", - status="FAILED_PRECONDITION", - ) - - with open(CLIENT_SECRET_FILE, "r") as f: - client_secrets_data = json.load(f) - - is_web = "web" in client_secrets_data - - if is_web: - redirect_uris = client_secrets_data["web"].get("redirect_uris", []) - redirect_uri = redirect_uris[0] if redirect_uris else "http://localhost:8080/" - - flow = Flow.from_client_secrets_file( - CLIENT_SECRET_FILE, - scopes=SCOPES, - redirect_uri=redirect_uri, - ) - authorization_url, _ = flow.authorization_url( - access_type="offline", - prompt="consent", - ) - print("Web-based authentication flow:") - print(f"URL: {authorization_url}") - redirect_response = input("Redirected URL or auth code: ").strip() - - if not redirect_response: - print_error_json( - "Redirected URL cannot be empty.", status="INVALID_ARGUMENT" - ) - - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" - - if "code=" in redirect_response or redirect_response.startswith("http"): - flow.fetch_token(authorization_response=redirect_response) - else: - flow.fetch_token(code=redirect_response) - credentials = flow.credentials - else: - flow = InstalledAppFlow.from_client_secrets_file( - CLIENT_SECRET_FILE, - scopes=SCOPES, - ) - credentials = flow.run_local_server(port=0) - - save_credentials(credentials) - print_json({"status": "SUCCESS", "message": "Logged in successfully."}) +DATATYPE_COMMANDS = { + "steps": ("steps", "steps", "steps"), + "heart-rate": ("heart_rate", "heartRate", "heart rate"), + "sleep": ("sleep", "sleep", "sleep"), + "distance": ("distance", "distance", "distance"), + "basal-energy-burned": ( + "basal_energy_burned", + "basalEnergyBurned", + "basal energy burned", + ), + "active-energy-burned": ( + "active_energy_burned", + "activeEnergyBurned", + "active energy burned", + ), + "total-calories": ("total_calories", "totalCalories", "total calories"), + "vo2-max": ("vo2_max", "vo2Max", "VO2 max"), + "weight": ("weight", "weight", "weight"), + "height": ("height", "height", "height"), + "bmi": ("bmi", "bmi", "BMI"), + "exercise": ("exercise", "exercise", "exercise"), + "daily-vo2-max": ("daily_vo2_max", "dailyVo2Max", "daily VO2 max"), + "daily-heart-rate-zones": ( + "daily_heart_rate_zones", + "dailyHeartRateZones", + "daily heart rate zones", + ), + "daily-sleep-temperature-derivations": ( + "daily_sleep_temperature_derivations", + "dailySleepTemperatureDerivations", + "daily sleep temperature derivations", + ), + "daily-respiratory-rate": ( + "daily_respiratory_rate", + "dailyRespiratoryRate", + "daily respiratory rate", + ), + "respiratory-rate-sleep-summary": ( + "respiratory_rate_sleep_summary", + "respiratoryRateSleepSummary", + "respiratory rate sleep summary", + ), + "electrocardiogram": ( + "electrocardiogram", + "electrocardiogram", + "electrocardiogram", + ), + "irregular-rhythm-notification": ( + "irregular_rhythm_notification", + "irregularRhythmNotification", + "irregular rhythm notification", + ), + "oxygen-saturation": ( + "oxygen_saturation", + "oxygenSaturation", + "oxygen saturation", + ), + "daily-oxygen-saturation": ( + "daily_oxygen_saturation", + "dailyOxygenSaturation", + "daily oxygen saturation", + ), + "floors": ("floors", "floors", "floors"), + "hydration-log": ("hydration_log", "hydrationLog", "hydration log"), + "nutrition-log": ("nutrition_log", "nutritionLog", "nutrition log"), + "daily-resting-heart-rate": ( + "daily_resting_heart_rate", + "dailyRestingHeartRate", + "daily resting heart rate", + ), + "heart-rate-variability": ( + "heart_rate_variability", + "heartRateVariability", + "heart rate variability", + ), + "daily-heart-rate-variability": ( + "daily_heart_rate_variability", + "dailyHeartRateVariability", + "daily heart rate variability", + ), + "altitude": ("altitude", "altitude", "altitude"), + "body-fat": ("body_fat", "bodyFat", "body fat"), + "active-minutes": ("active_minutes", "activeMinutes", "active minutes"), + "active-zone-minutes": ( + "active_zone_minutes", + "activeZoneMinutes", + "active zone minutes", + ), + "blood-glucose": ("blood_glucose", "bloodGlucose", "blood glucose"), + "core-body-temperature": ( + "core_body_temperature", + "coreBodyTemperature", + "core body temperature", + ), + "sedentary-period": ("sedentary_period", "sedentaryPeriod", "sedentary period"), + "swim-lengths-data": ( + "swim_lengths_data", + "swimLengthsData", + "swim lengths data", + ), + "run-vo2-max": ("run_vo2_max", "runVo2Max", "run VO2 max"), + "activity-level": ("activity_level", "activityLevel", "activity level"), + "time-in-heart-rate-zone": ( + "time_in_heart_rate_zone", + "timeInHeartRateZone", + "time in heart rate zone", + ), + "calories-in-heart-rate-zone": ( + "calories_in_heart_rate_zone", + "caloriesInHeartRateZone", + "calories in heart rate zone", + ), +} async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi: @@ -336,56 +204,6 @@ async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi: return api -async def execute_all_pages( - args, result: Any, field_name: str | None, pretty: bool -) -> None: - """Iterate and print items in NDJSON format for streaming output.""" - async for page in result: - if hasattr(page, "data_points"): - for item in page.data_points: - assert field_name is not None - print_json(serialize_datapoint(item, field_name), pretty=False) - elif hasattr(page, "reconciled_data_points"): - for item in page.reconciled_data_points: - assert field_name is not None - print_json( - serialize_reconciled_datapoint(item, field_name), pretty=False - ) - elif hasattr(page, "paired_devices"): - for item in page.paired_devices: - print_json(item.to_dict(), pretty=False) - elif hasattr(page, "subscribers"): - for item in page.subscribers: - print_json(item.to_dict(), pretty=False) - elif hasattr(page, "subscriptions"): - for item in page.subscriptions: - print_json(item.to_dict(), pretty=False) - - -def get_json_payload(args) -> dict[str, Any] | None: - """Extract and parse the raw JSON payload from --json argument if present.""" - if not hasattr(args, "json") or not args.json: - return None - try: - return json.loads(args.json) - except json.JSONDecodeError as err: - print_error_json(f"Invalid raw JSON payload: {err}", status="INVALID_ARGUMENT") - return None - - -def get_params_payload(args) -> dict[str, Any]: - """Extract and parse --params query variables if present.""" - if not hasattr(args, "params") or not args.params: - return {} - try: - return json.loads(args.params) - except json.JSONDecodeError as err: - print_error_json( - f"Invalid --params JSON payload: {err}", status="INVALID_ARGUMENT" - ) - return {} - - async def handle_datatype_cmd( args, api: GoogleHealthApi, @@ -486,7 +304,8 @@ async def handle_datatype_cmd( payload = get_json_payload(args) if payload is None: print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + "Please provide raw JSON input using --json.", + status="INVALID_ARGUMENT", ) assert payload is not None @@ -496,7 +315,10 @@ async def handle_datatype_cmd( validate_resource_name(args.data_point_id) path += f"/{args.data_point_id}" check_dry_run( - args.dry_run, "POST" if sub == "create" else "PATCH", path, payload + args.dry_run, + "POST" if sub == "create" else "PATCH", + path, + payload, ) dp = DataPoint.from_api_dict(sub_api.data_type, payload) @@ -510,9 +332,8 @@ async def handle_datatype_cmd( validate_resource_name(args.data_point_id) check_dry_run( args.dry_run, - "POST", - f"v4/users/me/dataTypes/{key}/dataPoints:batchDelete", - {"names": [f"users/me/dataTypes/{key}/dataPoints/{args.data_point_id}"]}, + "DELETE", + f"v4/users/me/dataTypes/{key}/dataPoints/{args.data_point_id}", ) await sub_api.delete(args.data_point_id) print_json( @@ -524,767 +345,6 @@ async def handle_datatype_cmd( ) -async def handle_steps_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle steps subcommands.""" - await handle_datatype_cmd(args, api, api.steps, "steps", "steps", "steps", pretty) - - -async def handle_heart_rate_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle heart rate subcommands.""" - await handle_datatype_cmd( - args, api, api.heart_rate, "heartRate", "heart-rate", "heart rate", pretty - ) - - -async def handle_sleep_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle sleep subcommands.""" - await handle_datatype_cmd(args, api, api.sleep, "sleep", "sleep", "sleep", pretty) - - -async def handle_distance_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle distance subcommands.""" - await handle_datatype_cmd( - args, api, api.distance, "distance", "distance", "distance", pretty - ) - - -async def handle_basal_energy_burned_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle basal energy burned subcommands.""" - await handle_datatype_cmd( - args, - api, - api.basal_energy_burned, - "basalEnergyBurned", - "basal-energy-burned", - "basal energy burned", - pretty, - ) - - -async def handle_active_energy_burned_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle active energy burned subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_energy_burned, - "activeEnergyBurned", - "active-energy-burned", - "active energy burned", - pretty, - ) - - -async def handle_total_calories_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle total calories subcommands.""" - await handle_datatype_cmd( - args, - api, - api.total_calories, - "totalCalories", - "total-calories", - "total calories", - pretty, - ) - - -async def handle_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle VO2 max subcommands.""" - await handle_datatype_cmd( - args, api, api.vo2_max, "vo2Max", "vo2-max", "VO2 max", pretty - ) - - -async def handle_weight_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle weight subcommands.""" - await handle_datatype_cmd( - args, api, api.weight, "weight", "weight", "weight", pretty - ) - - -async def handle_height_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle height subcommands.""" - await handle_datatype_cmd( - args, api, api.height, "height", "height", "height", pretty - ) - - -async def handle_bmi_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle bmi subcommands.""" - await handle_datatype_cmd(args, api, api.bmi, "bmi", "bmi", "BMI", pretty) - - -async def handle_exercise_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle exercise subcommands.""" - await handle_datatype_cmd( - args, api, api.exercise, "exercise", "exercise", "exercise", pretty - ) - - -async def handle_daily_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle daily VO2 max subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_vo2_max, - "dailyVo2Max", - "daily-vo2-max", - "daily VO2 max", - pretty, - ) - - -async def handle_daily_heart_rate_zones_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily heart rate zones subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_heart_rate_zones, - "dailyHeartRateZones", - "daily-heart-rate-zones", - "daily heart rate zones", - pretty, - ) - - -async def handle_daily_sleep_temperature_derivations_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily sleep temperature derivations subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_sleep_temperature_derivations, - "dailySleepTemperatureDerivations", - "daily-sleep-temperature-derivations", - "daily sleep temperature derivations", - pretty, - ) - - -async def handle_daily_respiratory_rate_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily respiratory rate subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_respiratory_rate, - "dailyRespiratoryRate", - "daily-respiratory-rate", - "daily respiratory rate", - pretty, - ) - - -async def handle_respiratory_rate_sleep_summary_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle respiratory rate sleep summary subcommands.""" - await handle_datatype_cmd( - args, - api, - api.respiratory_rate_sleep_summary, - "respiratoryRateSleepSummary", - "respiratory-rate-sleep-summary", - "respiratory rate sleep summary", - pretty, - ) - - -async def handle_electrocardiogram_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle electrocardiogram subcommands.""" - await handle_datatype_cmd( - args, - api, - api.electrocardiogram, - "electrocardiogram", - "electrocardiogram", - "electrocardiogram", - pretty, - ) - - -async def handle_irregular_rhythm_notification_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle irregular rhythm notification subcommands.""" - await handle_datatype_cmd( - args, - api, - api.irregular_rhythm_notification, - "irregularRhythmNotification", - "irregular-rhythm-notification", - "irregular rhythm notification", - pretty, - ) - - -async def handle_oxygen_saturation_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle oxygen saturation subcommands.""" - await handle_datatype_cmd( - args, - api, - api.oxygen_saturation, - "oxygenSaturation", - "oxygen-saturation", - "oxygen saturation", - pretty, - ) - - -async def handle_daily_oxygen_saturation_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily oxygen saturation subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_oxygen_saturation, - "dailyOxygenSaturation", - "daily-oxygen-saturation", - "daily oxygen saturation", - pretty, - ) - - -async def handle_floors_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle floors subcommands.""" - await handle_datatype_cmd( - args, api, api.floors, "floors", "floors", "floors", pretty - ) - - -async def handle_hydration_log_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle hydration log subcommands.""" - await handle_datatype_cmd( - args, - api, - api.hydration_log, - "hydrationLog", - "hydration-log", - "hydration log", - pretty, - ) - - -async def handle_nutrition_log_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle nutrition log subcommands.""" - await handle_datatype_cmd( - args, - api, - api.nutrition_log, - "nutritionLog", - "nutrition-log", - "nutrition log", - pretty, - ) - - -async def handle_daily_resting_heart_rate_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily resting heart rate subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_resting_heart_rate, - "dailyRestingHeartRate", - "daily-resting-heart-rate", - "daily resting heart rate", - pretty, - ) - - -async def handle_heart_rate_variability_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle heart rate variability subcommands.""" - await handle_datatype_cmd( - args, - api, - api.heart_rate_variability, - "heartRateVariability", - "heart-rate-variability", - "heart rate variability", - pretty, - ) - - -async def handle_daily_heart_rate_variability_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily heart rate variability subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_heart_rate_variability, - "dailyHeartRateVariability", - "daily-heart-rate-variability", - "daily heart rate variability", - pretty, - ) - - -async def handle_altitude_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle altitude subcommands.""" - await handle_datatype_cmd( - args, api, api.altitude, "altitude", "altitude", "altitude", pretty - ) - - -async def handle_body_fat_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle body fat subcommands.""" - await handle_datatype_cmd( - args, api, api.body_fat, "bodyFat", "body-fat", "body fat", pretty - ) - - -async def handle_active_minutes_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle active minutes subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_minutes, - "activeMinutes", - "active-minutes", - "active minutes", - pretty, - ) - - -async def handle_active_zone_minutes_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle active zone minutes subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_zone_minutes, - "activeZoneMinutes", - "active-zone-minutes", - "active zone minutes", - pretty, - ) - - -async def handle_blood_glucose_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle blood glucose subcommands.""" - await handle_datatype_cmd( - args, - api, - api.blood_glucose, - "bloodGlucose", - "blood-glucose", - "blood glucose", - pretty, - ) - - -async def handle_core_body_temperature_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle core body temperature subcommands.""" - await handle_datatype_cmd( - args, - api, - api.core_body_temperature, - "coreBodyTemperature", - "core-body-temperature", - "core body temperature", - pretty, - ) - - -async def handle_sedentary_period_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle sedentary period subcommands.""" - await handle_datatype_cmd( - args, - api, - api.sedentary_period, - "sedentaryPeriod", - "sedentary-period", - "sedentary period", - pretty, - ) - - -async def handle_swim_lengths_data_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle swim lengths data subcommands.""" - await handle_datatype_cmd( - args, - api, - api.swim_lengths_data, - "swimLengthsData", - "swim-lengths-data", - "swim lengths data", - pretty, - ) - - -async def handle_run_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle run VO2 max subcommands.""" - await handle_datatype_cmd( - args, - api, - api.run_vo2_max, - "runVo2Max", - "run-vo2-max", - "run VO2 max", - pretty, - ) - - -async def handle_activity_level_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle activity level subcommands.""" - await handle_datatype_cmd( - args, - api, - api.activity_level, - "activityLevel", - "activity-level", - "activity level", - pretty, - ) - - -async def handle_time_in_heart_rate_zone_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle time in heart rate zone subcommands.""" - await handle_datatype_cmd( - args, - api, - api.time_in_heart_rate_zone, - "timeInHeartRateZone", - "time-in-heart-rate-zone", - "time in heart rate zone", - pretty, - ) - - -async def handle_calories_in_heart_rate_zone_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle calories in heart rate zone subcommands.""" - await handle_datatype_cmd( - args, - api, - api.calories_in_heart_rate_zone, - "caloriesInHeartRateZone", - "calories-in-heart-rate-zone", - "calories in heart rate zone", - pretty, - ) - - -async def handle_profile_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle profile subcommands.""" - sub = args.subcommand - if sub == "get": - result = await api.get_profile() - print_json(serialize_response(result), pretty) - elif sub == "update": - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - "v4/users/me/profile", - {"payload": payload, "updateMask": update_mask}, - ) - - prof = Profile.from_dict(payload) - result = await api.update_profile(prof, update_mask=update_mask) - print_json(serialize_response(result), pretty) - - -async def handle_userinfo_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle userinfo subcommands.""" - result = await api.get_user_info() - print_json(serialize_response(result), pretty) - - -async def handle_settings_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle settings subcommands.""" - sub = args.subcommand - if sub == "get": - result = await api.get_settings() - print_json(serialize_response(result), pretty) - elif sub == "update": - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - "v4/users/me/settings", - {"payload": payload, "updateMask": update_mask}, - ) - - sett = Settings.from_dict(payload) - result = await api.update_settings(sett, update_mask=update_mask) - print_json(serialize_response(result), pretty) - - -async def handle_devices_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle devices subcommands.""" - sub = args.subcommand - if sub == "list": - limit = args.limit - page_token = args.page_token - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - - result = await api.paired_devices.list(page_size=pageSize, page_token=pageToken) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - elif sub == "get": - validate_resource_name(args.device_id) - result = await api.paired_devices.get(device_id=args.device_id) - print_json(serialize_response(result), pretty) - - -async def handle_identity_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle identity subcommands.""" - if args.subcommand == "get": - result = await api.get_identity() - print_json(serialize_response(result), pretty) - - -async def handle_irn_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle IRN subcommands.""" - if args.subcommand == "get": - result = await api.get_irn_profile() - print_json(serialize_response(result), pretty) - - -async def handle_subscribers_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle subscribers subcommands.""" - sub = args.subcommand - if sub == "list": - project = args.project - limit = args.limit - page_token = args.page_token - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - - result = await api.subscribers.list( - project=project, page_size=pageSize, page_token=pageToken - ) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - - elif sub == "create": - payload = get_json_payload(args) - subscriber_id = None - if payload: - endpoint_uri = payload.get("endpointUri") - endpoint_auth = payload.get("endpointAuthorization", {}) - endpoint_secret = endpoint_auth.get("secret") - configs = [ - SubscriberConfig.from_dict(c) - for c in payload.get("subscriberConfigs", []) - ] - else: - endpoint_uri = args.endpoint_uri - endpoint_secret = args.endpoint_secret - configs = [] - - params = get_params_payload(args) - subscriber_id = params.get("subscriberId", args.subscriber_id) - - if endpoint_uri is None or endpoint_secret is None: - print_error_json( - "Missing endpointUri or endpoint secret.", status="INVALID_ARGUMENT" - ) - assert isinstance(endpoint_uri, str) - assert isinstance(endpoint_secret, str) - - payload_dry = { - "endpointUri": endpoint_uri, - "endpointAuthorization": {"secret": endpoint_secret}, - "subscriberConfigs": [c.to_dict() for c in configs], - "subscriberId": subscriber_id, - } - check_dry_run( - args.dry_run, "POST", f"v4/projects/{args.project}/subscribers", payload_dry - ) - - result = await api.subscribers.create( - project=args.project, - endpoint_uri=endpoint_uri, - endpoint_authorization_secret=endpoint_secret, - subscriber_configs=configs if configs else None, - subscriber_id=subscriber_id, - ) - print_json(serialize_response(result), pretty) - - elif sub == "patch": - validate_resource_name(args.name) - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - f"v4/{args.name}", - {"payload": payload, "updateMask": update_mask}, - ) - - sub_obj = Subscriber.from_dict(payload) - result = await api.subscribers.patch( - args.name, sub_obj, update_mask=update_mask - ) - print_json(serialize_response(result), pretty) - - elif sub == "delete": - validate_resource_name(args.name) - params = get_params_payload(args) - force = params.get("force", args.force) - - check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}", {"force": force}) - result = await api.subscribers.delete(args.name, force=force) - print_json(serialize_response(result), pretty) - - -async def handle_subscriptions_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle subscriptions subcommands.""" - sub = args.subcommand - if sub == "list": - limit = args.limit - page_token = args.page_token - filter_expr = args.filter - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - filter_str = params.get("filter", filter_expr) - - result = await api.subscribers.subscriptions.list( - parent_subscriber=args.parent_subscriber, - filter=filter_str, - page_size=pageSize, - page_token=pageToken, - ) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - - elif sub == "create": - payload = get_json_payload(args) - subscription_id = None - if payload: - user = payload.get("user") - data_types = payload.get("dataTypes") - else: - user = args.user - data_types = args.data_types - - params = get_params_payload(args) - subscription_id = params.get("subscriptionId", args.subscription_id) - - if user is None: - print_error_json("Missing user parameter.", status="INVALID_ARGUMENT") - assert isinstance(user, str) - - payload_dry = { - "user": user, - "dataTypes": data_types, - "subscriptionId": subscription_id, - } - check_dry_run( - args.dry_run, - "POST", - f"v4/{args.parent_subscriber}/subscriptions", - payload_dry, - ) - - result = await api.subscribers.subscriptions.create( - parent_subscriber=args.parent_subscriber, - user=user, - data_types=data_types, - subscription_id=subscription_id, - ) - print_json(serialize_response(result), pretty) - - elif sub == "patch": - validate_resource_name(args.name) - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - f"v4/{args.name}", - {"payload": payload, "updateMask": update_mask}, - ) - - sub_obj = Subscription.from_dict(payload) - result = await api.subscribers.subscriptions.patch( - args.name, sub_obj, update_mask=update_mask - ) - print_json(serialize_response(result), pretty) - - elif sub == "delete": - validate_resource_name(args.name) - check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}") - await api.subscribers.subscriptions.delete(args.name) - print_json( - {"status": "SUCCESS", "message": f"Deleted subscription {args.name}"}, - pretty, - ) - - async def async_run_cmd(args) -> None: """Async main routine that handles setup, context variables, and routing.""" # Set fields context variable @@ -1296,84 +356,18 @@ async def async_run_cmd(args) -> None: try: api = await setup_client(session) cmd = args.command - if cmd == "steps": - await handle_steps_cmd(args, api, pretty) - elif cmd == "heart-rate": - await handle_heart_rate_cmd(args, api, pretty) - elif cmd == "sleep": - await handle_sleep_cmd(args, api, pretty) - elif cmd == "distance": - await handle_distance_cmd(args, api, pretty) - elif cmd == "basal-energy-burned": - await handle_basal_energy_burned_cmd(args, api, pretty) - elif cmd == "vo2-max": - await handle_vo2_max_cmd(args, api, pretty) - elif cmd == "weight": - await handle_weight_cmd(args, api, pretty) - elif cmd == "height": - await handle_height_cmd(args, api, pretty) - elif cmd == "bmi": - await handle_bmi_cmd(args, api, pretty) - elif cmd == "exercise": - await handle_exercise_cmd(args, api, pretty) - elif cmd == "daily-vo2-max": - await handle_daily_vo2_max_cmd(args, api, pretty) - elif cmd == "daily-heart-rate-zones": - await handle_daily_heart_rate_zones_cmd(args, api, pretty) - elif cmd == "daily-sleep-temperature-derivations": - await handle_daily_sleep_temperature_derivations_cmd(args, api, pretty) - elif cmd == "daily-respiratory-rate": - await handle_daily_respiratory_rate_cmd(args, api, pretty) - elif cmd == "respiratory-rate-sleep-summary": - await handle_respiratory_rate_sleep_summary_cmd(args, api, pretty) - elif cmd == "electrocardiogram": - await handle_electrocardiogram_cmd(args, api, pretty) - elif cmd == "irregular-rhythm-notification": - await handle_irregular_rhythm_notification_cmd(args, api, pretty) - elif cmd == "oxygen-saturation": - await handle_oxygen_saturation_cmd(args, api, pretty) - elif cmd == "daily-oxygen-saturation": - await handle_daily_oxygen_saturation_cmd(args, api, pretty) - elif cmd == "active-energy-burned": - await handle_active_energy_burned_cmd(args, api, pretty) - elif cmd == "total-calories": - await handle_total_calories_cmd(args, api, pretty) - elif cmd == "floors": - await handle_floors_cmd(args, api, pretty) - elif cmd == "hydration-log": - await handle_hydration_log_cmd(args, api, pretty) - elif cmd == "nutrition-log": - await handle_nutrition_log_cmd(args, api, pretty) - elif cmd == "daily-resting-heart-rate": - await handle_daily_resting_heart_rate_cmd(args, api, pretty) - elif cmd == "heart-rate-variability": - await handle_heart_rate_variability_cmd(args, api, pretty) - elif cmd == "daily-heart-rate-variability": - await handle_daily_heart_rate_variability_cmd(args, api, pretty) - elif cmd == "altitude": - await handle_altitude_cmd(args, api, pretty) - elif cmd == "body-fat": - await handle_body_fat_cmd(args, api, pretty) - elif cmd == "active-minutes": - await handle_active_minutes_cmd(args, api, pretty) - elif cmd == "active-zone-minutes": - await handle_active_zone_minutes_cmd(args, api, pretty) - elif cmd == "blood-glucose": - await handle_blood_glucose_cmd(args, api, pretty) - elif cmd == "core-body-temperature": - await handle_core_body_temperature_cmd(args, api, pretty) - elif cmd == "sedentary-period": - await handle_sedentary_period_cmd(args, api, pretty) - elif cmd == "swim-lengths-data": - await handle_swim_lengths_data_cmd(args, api, pretty) - elif cmd == "run-vo2-max": - await handle_run_vo2_max_cmd(args, api, pretty) - elif cmd == "activity-level": - await handle_activity_level_cmd(args, api, pretty) - elif cmd == "time-in-heart-rate-zone": - await handle_time_in_heart_rate_zone_cmd(args, api, pretty) - elif cmd == "calories-in-heart-rate-zone": - await handle_calories_in_heart_rate_zone_cmd(args, api, pretty) + if cmd in DATATYPE_COMMANDS: + api_attr, field_name, display_name = DATATYPE_COMMANDS[cmd] + sub_api = getattr(api, api_attr) + await handle_datatype_cmd( + args, + api, + sub_api, + field_name, + cmd, + display_name, + pretty, + ) elif cmd == "profile": await handle_profile_cmd(args, api, pretty) elif cmd == "userinfo": diff --git a/google_health_api/cli/subcommands/__init__.py b/google_health_api/cli/subcommands/__init__.py new file mode 100644 index 0000000..cacdeb7 --- /dev/null +++ b/google_health_api/cli/subcommands/__init__.py @@ -0,0 +1 @@ +"""Subcommands for Google Health CLI.""" diff --git a/google_health_api/cli/subcommands/devices.py b/google_health_api/cli/subcommands/devices.py new file mode 100644 index 0000000..0fc67f2 --- /dev/null +++ b/google_health_api/cli/subcommands/devices.py @@ -0,0 +1,33 @@ +"""Devices subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import ( + execute_all_pages, + get_params_payload, + print_json, + serialize_response, +) +from ..validation import validate_resource_name + + +async def handle_devices_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle devices subcommands.""" + sub = args.subcommand + if sub == "list": + limit = args.limit + page_token = args.page_token + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + + result = await api.paired_devices.list(page_size=pageSize, page_token=pageToken) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + elif sub == "get": + validate_resource_name(args.device_id) + result = await api.paired_devices.get(device_id=args.device_id) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/identity.py b/google_health_api/cli/subcommands/identity.py new file mode 100644 index 0000000..ee58812 --- /dev/null +++ b/google_health_api/cli/subcommands/identity.py @@ -0,0 +1,19 @@ +"""Identity subcommands for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import print_json, serialize_response + + +async def handle_identity_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle identity subcommands.""" + if args.subcommand == "get": + result = await api.get_identity() + print_json(serialize_response(result), pretty) + + +async def handle_irn_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle IRN subcommands.""" + if args.subcommand == "get": + result = await api.get_irn_profile() + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/login.py b/google_health_api/cli/subcommands/login.py new file mode 100644 index 0000000..2893ca4 --- /dev/null +++ b/google_health_api/cli/subcommands/login.py @@ -0,0 +1,70 @@ +"""Login subcommand for Google Health CLI.""" + +import json +import os +import sys + +from google_auth_oauthlib.flow import Flow, InstalledAppFlow + +from ..auth import CLIENT_SECRET_FILE, SCOPES, save_credentials +from ..utils import print_error_json, print_json + + +def cmd_login(args) -> None: + """Execute interactive OAuth login flow.""" + if not os.path.exists(CLIENT_SECRET_FILE): + print_error_json( + f"Client secrets file '{CLIENT_SECRET_FILE}' not found.", + status="NOT_FOUND", + ) + + if not sys.stdin.isatty(): + print_error_json( + "Cannot run interactive login in a headless environment.", + status="FAILED_PRECONDITION", + ) + + with open(CLIENT_SECRET_FILE, "r") as f: + client_secrets_data = json.load(f) + + is_web = "web" in client_secrets_data + + if is_web: + redirect_uris = client_secrets_data["web"].get("redirect_uris", []) + redirect_uri = redirect_uris[0] if redirect_uris else "http://localhost:8080/" + + flow = Flow.from_client_secrets_file( + CLIENT_SECRET_FILE, + scopes=SCOPES, + redirect_uri=redirect_uri, + ) + authorization_url, _ = flow.authorization_url( + access_type="offline", + prompt="consent", + ) + print("Web-based authentication flow:") + print(f"URL: {authorization_url}") + redirect_response = input("Redirected URL or auth code: ").strip() + + if not redirect_response: + print_error_json( + "Redirected URL cannot be empty.", status="INVALID_ARGUMENT" + ) + + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + + if "code=" in redirect_response or redirect_response.startswith("http"): + flow.fetch_token(authorization_response=redirect_response) + else: + flow.fetch_token(code=redirect_response) + credentials = flow.credentials + else: + flow = InstalledAppFlow.from_client_secrets_file( + CLIENT_SECRET_FILE, + scopes=SCOPES, + ) + credentials = flow.run_local_server(port=0) + + save_credentials(credentials) + print_json({"status": "SUCCESS", "message": "Logged in successfully."}) diff --git a/google_health_api/cli/subcommands/profile.py b/google_health_api/cli/subcommands/profile.py new file mode 100644 index 0000000..18c3693 --- /dev/null +++ b/google_health_api/cli/subcommands/profile.py @@ -0,0 +1,42 @@ +"""Profile subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Profile + +from ..utils import ( + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run + + +async def handle_profile_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle profile subcommands.""" + sub = args.subcommand + if sub == "get": + result = await api.get_profile() + print_json(serialize_response(result), pretty) + elif sub == "update": + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + "v4/users/me/profile", + {"payload": payload, "updateMask": update_mask}, + ) + + prof = Profile.from_dict(payload) + result = await api.update_profile(prof, update_mask=update_mask) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/settings.py b/google_health_api/cli/subcommands/settings.py new file mode 100644 index 0000000..9d1e99d --- /dev/null +++ b/google_health_api/cli/subcommands/settings.py @@ -0,0 +1,42 @@ +"""Settings subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Settings + +from ..utils import ( + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run + + +async def handle_settings_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle settings subcommands.""" + sub = args.subcommand + if sub == "get": + result = await api.get_settings() + print_json(serialize_response(result), pretty) + elif sub == "update": + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + "v4/users/me/settings", + {"payload": payload, "updateMask": update_mask}, + ) + + sett = Settings.from_dict(payload) + result = await api.update_settings(sett, update_mask=update_mask) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/subscribers.py b/google_health_api/cli/subcommands/subscribers.py new file mode 100644 index 0000000..55c9f29 --- /dev/null +++ b/google_health_api/cli/subcommands/subscribers.py @@ -0,0 +1,114 @@ +"""Subscribers subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Subscriber, SubscriberConfig + +from ..utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run, validate_resource_name + + +async def handle_subscribers_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle subscribers subcommands.""" + sub = args.subcommand + if sub == "list": + project = args.project + limit = args.limit + page_token = args.page_token + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + + result = await api.subscribers.list( + project=project, page_size=pageSize, page_token=pageToken + ) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + + elif sub == "create": + payload = get_json_payload(args) + subscriber_id = None + if payload: + endpoint_uri = payload.get("endpointUri") + endpoint_auth = payload.get("endpointAuthorization", {}) + endpoint_secret = endpoint_auth.get("secret") + configs = [ + SubscriberConfig.from_dict(c) + for c in payload.get("subscriberConfigs", []) + ] + else: + endpoint_uri = args.endpoint_uri + endpoint_secret = args.endpoint_secret + configs = [] + + params = get_params_payload(args) + subscriber_id = params.get("subscriberId", args.subscriber_id) + + if endpoint_uri is None or endpoint_secret is None: + print_error_json( + "Missing endpointUri or endpoint secret.", status="INVALID_ARGUMENT" + ) + assert isinstance(endpoint_uri, str) + assert isinstance(endpoint_secret, str) + + payload_dry = { + "endpointUri": endpoint_uri, + "endpointAuthorization": {"secret": endpoint_secret}, + "subscriberConfigs": [c.to_dict() for c in configs], + "subscriberId": subscriber_id, + } + check_dry_run( + args.dry_run, "POST", f"v4/projects/{args.project}/subscribers", payload_dry + ) + + result = await api.subscribers.create( + project=args.project, + endpoint_uri=endpoint_uri, + endpoint_authorization_secret=endpoint_secret, + subscriber_configs=configs if configs else None, + subscriber_id=subscriber_id, + ) + print_json(serialize_response(result), pretty) + + elif sub == "patch": + validate_resource_name(args.name) + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + f"v4/{args.name}", + {"payload": payload, "updateMask": update_mask}, + ) + + sub_obj = Subscriber.from_dict(payload) + result = await api.subscribers.patch( + args.name, sub_obj, update_mask=update_mask + ) + print_json(serialize_response(result), pretty) + + elif sub == "delete": + validate_resource_name(args.name) + params = get_params_payload(args) + force = params.get("force", args.force) + + check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}", {"force": force}) + result = await api.subscribers.delete(args.name, force=force) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/subscriptions.py b/google_health_api/cli/subcommands/subscriptions.py new file mode 100644 index 0000000..8b125dc --- /dev/null +++ b/google_health_api/cli/subcommands/subscriptions.py @@ -0,0 +1,110 @@ +"""Subscriptions subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Subscription + +from ..utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run, validate_resource_name + + +async def handle_subscriptions_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle subscriptions subcommands.""" + sub = args.subcommand + if sub == "list": + limit = args.limit + page_token = args.page_token + filter_expr = args.filter + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + filter_str = params.get("filter", filter_expr) + + result = await api.subscribers.subscriptions.list( + parent_subscriber=args.parent_subscriber, + filter=filter_str, + page_size=pageSize, + page_token=pageToken, + ) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + + elif sub == "create": + payload = get_json_payload(args) + subscription_id = None + if payload: + user = payload.get("user") + data_types = payload.get("dataTypes") + else: + user = args.user + data_types = args.data_types + + params = get_params_payload(args) + subscription_id = params.get("subscriptionId", args.subscription_id) + + if user is None: + print_error_json("Missing user parameter.", status="INVALID_ARGUMENT") + assert isinstance(user, str) + + payload_dry = { + "user": user, + "dataTypes": data_types, + "subscriptionId": subscription_id, + } + check_dry_run( + args.dry_run, + "POST", + f"v4/{args.parent_subscriber}/subscriptions", + payload_dry, + ) + + result = await api.subscribers.subscriptions.create( + parent_subscriber=args.parent_subscriber, + user=user, + data_types=data_types, + subscription_id=subscription_id, + ) + print_json(serialize_response(result), pretty) + + elif sub == "patch": + validate_resource_name(args.name) + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + f"v4/{args.name}", + {"payload": payload, "updateMask": update_mask}, + ) + + sub_obj = Subscription.from_dict(payload) + result = await api.subscribers.subscriptions.patch( + args.name, sub_obj, update_mask=update_mask + ) + print_json(serialize_response(result), pretty) + + elif sub == "delete": + validate_resource_name(args.name) + check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}") + await api.subscribers.subscriptions.delete(args.name) + print_json( + {"status": "SUCCESS", "message": f"Deleted subscription {args.name}"}, + pretty, + ) diff --git a/google_health_api/cli/subcommands/userinfo.py b/google_health_api/cli/subcommands/userinfo.py new file mode 100644 index 0000000..80f2e22 --- /dev/null +++ b/google_health_api/cli/subcommands/userinfo.py @@ -0,0 +1,11 @@ +"""UserInfo subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import print_json, serialize_response + + +async def handle_userinfo_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle userinfo subcommands.""" + result = await api.get_user_info() + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/utils.py b/google_health_api/cli/utils.py new file mode 100644 index 0000000..12fc1b0 --- /dev/null +++ b/google_health_api/cli/utils.py @@ -0,0 +1,135 @@ +"""Shared CLI utilities.""" + +import json +import sys +from typing import Any, NoReturn + +from google_health_api.model import DataPoint, ReconciledDataPoint + + +def print_json(data: Any, pretty: bool = True) -> None: + """Helper to output JSON data, respect pretty setting.""" + if pretty: + print(json.dumps(data, indent=2)) + else: + print(json.dumps(data)) + + +def print_error_json(message: str, status: str = "INTERNAL") -> NoReturn: + """Print standard JSON error and exit.""" + res = { + "error": { + "status": status, + "message": message, + } + } + print_json(res) + sys.exit(1) + + +def serialize_datapoint(dp: DataPoint, field_name: str) -> dict[str, Any]: + """Serialize generic DataPoint class to dictionary matching API payload structure.""" + res: dict[str, Any] = {} + if dp.name: + res["name"] = dp.name + if dp.data_source: + res["dataSource"] = dp.data_source + if hasattr(dp.data, "to_dict"): + res[field_name] = dp.data.to_dict() + else: + res[field_name] = dp.data + return res + + +def serialize_reconciled_datapoint( + rdp: ReconciledDataPoint, field_name: str +) -> dict[str, Any]: + """Serialize ReconciledDataPoint to dictionary structure.""" + return {"dataPoint": serialize_datapoint(rdp.data_point, field_name)} + + +def serialize_response(result: Any, field_name: str | None = None) -> Any: + """Convert API response object/paginated result to JSON-serializable structure.""" + if hasattr(result, "to_dict"): + return result.to_dict() + if hasattr(result, "data_points"): + return { + "dataPoints": [ + serialize_datapoint(dp, field_name or "") for dp in result.data_points + ], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "reconciled_data_points"): + return { + "reconciledDataPoints": [ + serialize_reconciled_datapoint(rdp, field_name or "") + for rdp in result.reconciled_data_points + ], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "paired_devices"): + return { + "pairedDevices": [dev.to_dict() for dev in result.paired_devices], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "subscribers"): + return { + "subscribers": [sub.to_dict() for sub in result.subscribers], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "subscriptions"): + return { + "subscriptions": [sub.to_dict() for sub in result.subscriptions], + "nextPageToken": result.next_page_token, + } + return result + + +def get_json_payload(args) -> dict[str, Any] | None: + """Extract and parse raw JSON input payload if present.""" + if not hasattr(args, "json") or not args.json: + return None + try: + return json.loads(args.json) + except json.JSONDecodeError as err: + print_error_json(f"Invalid raw JSON payload: {err}", status="INVALID_ARGUMENT") + return None + + +def get_params_payload(args) -> dict[str, Any]: + """Extract and parse --params query variables if present.""" + if not hasattr(args, "params") or not args.params: + return {} + try: + return json.loads(args.params) + except json.JSONDecodeError as err: + print_error_json( + f"Invalid --params JSON payload: {err}", status="INVALID_ARGUMENT" + ) + return {} + + +async def execute_all_pages( + args, result: Any, field_name: str | None, pretty: bool +) -> None: + """Iterate and print items in NDJSON format for streaming output.""" + async for page in result: + if hasattr(page, "data_points"): + for item in page.data_points: + assert field_name is not None + print_json(serialize_datapoint(item, field_name), pretty=False) + elif hasattr(page, "reconciled_data_points"): + for item in page.reconciled_data_points: + assert field_name is not None + print_json( + serialize_reconciled_datapoint(item, field_name), pretty=False + ) + elif hasattr(page, "paired_devices"): + for item in page.paired_devices: + print_json(item.to_dict(), pretty=False) + elif hasattr(page, "subscribers"): + for item in page.subscribers: + print_json(item.to_dict(), pretty=False) + elif hasattr(page, "subscriptions"): + for item in page.subscriptions: + print_json(item.to_dict(), pretty=False) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..b277e94 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,43 @@ +"""Shared pytest fixtures for CLI tests.""" + +import sys +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from google_health_api.cli.main import main + + +def run_cli(args: list[str]) -> None: + """Helper to run the CLI with specific arguments.""" + with patch.object(sys, "argv", ["google-health-cli", *args]): + main() + + +@pytest.fixture +def mock_load_credentials() -> Generator[MagicMock]: + """Fixture to mock load_credentials_or_env.""" + with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: + yield mock + + +@pytest.fixture +def mock_setup_client() -> Generator[MagicMock]: + """Fixture to mock setup_client.""" + with patch("google_health_api.cli.commands.setup_client") as mock: + yield mock + + +@pytest.fixture +def mock_flow_cls() -> Generator[MagicMock]: + """Fixture to mock Flow in login subcommand.""" + with patch("google_health_api.cli.subcommands.login.Flow") as mock: + yield mock + + +@pytest.fixture +def mock_installed_flow_cls() -> Generator[MagicMock]: + """Fixture to mock InstalledAppFlow in login subcommand.""" + with patch("google_health_api.cli.subcommands.login.InstalledAppFlow") as mock: + yield mock diff --git a/tests/cli/subcommands/__init__.py b/tests/cli/subcommands/__init__.py new file mode 100644 index 0000000..b212448 --- /dev/null +++ b/tests/cli/subcommands/__init__.py @@ -0,0 +1 @@ +"""Subcommand tests for Google Health CLI.""" diff --git a/tests/cli/subcommands/test_devices.py b/tests/cli/subcommands/test_devices.py new file mode 100644 index 0000000..d09e3ba --- /dev/null +++ b/tests/cli/subcommands/test_devices.py @@ -0,0 +1,38 @@ +"""Tests for devices CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_devices_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test devices CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + mock_devices_api = AsyncMock() + mock_api.paired_devices = mock_devices_api + + # devices list + mock_dev_res = MagicMock(spec=["to_dict"]) + mock_dev_res.to_dict.return_value = {"pairedDevices": []} + mock_devices_api.list.return_value = mock_dev_res + run_cli(["devices", "list"]) + mock_devices_api.list.assert_called_once() + capsys.readouterr() + + # devices get + mock_dev = MagicMock(spec=["to_dict"]) + mock_dev.to_dict.return_value = {"id": "dev123"} + mock_devices_api.get.return_value = mock_dev + run_cli(["devices", "get", "dev123"]) + captured = capsys.readouterr() + assert json.loads(captured.out)["id"] == "dev123" diff --git a/tests/cli/subcommands/test_identity.py b/tests/cli/subcommands/test_identity.py new file mode 100644 index 0000000..63912a6 --- /dev/null +++ b/tests/cli/subcommands/test_identity.py @@ -0,0 +1,30 @@ +"""Tests for identity and irn CLI commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_identity_and_irn( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test identity and irn CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + mock_api.get_identity = AsyncMock( + return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"subject": "user123"}) + ) + run_cli(["identity", "get"]) + mock_api.get_identity.assert_called_once() + + mock_api.get_irn_profile = AsyncMock( + return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) + ) + run_cli(["irn", "get"]) + mock_api.get_irn_profile.assert_called_once() diff --git a/tests/cli/subcommands/test_login.py b/tests/cli/subcommands/test_login.py new file mode 100644 index 0000000..66575c1 --- /dev/null +++ b/tests/cli/subcommands/test_login.py @@ -0,0 +1,192 @@ +"""Tests for login and auth flow CLI commands.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from google_health_api.cli.auth import ( + CredentialsAuth, + EnvAuth, + load_credentials_or_env, +) +from google_health_api.cli.subcommands.login import cmd_login +from tests.cli.conftest import run_cli + + +@pytest.mark.asyncio +async def test_credentials_auth_refresh( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test CredentialsAuth refreshing expired credentials.""" + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) + + creds_mock = MagicMock() + creds_mock.valid = False + creds_mock.token = "refreshed-token" + creds_mock.to_json.return_value = '{"token": "refreshed-token"}' + + session_mock = MagicMock() + auth = CredentialsAuth(session_mock, creds_mock) + + token = await auth.async_get_access_token() + assert token == "refreshed-token" + assert creds_mock.refresh.called + assert token_file.exists() + + +@pytest.mark.asyncio +async def test_env_auth() -> None: + """Test EnvAuth token retrieval.""" + session_mock = MagicMock() + auth = EnvAuth(session_mock, "env-secret-token") + token = await auth.async_get_access_token() + assert token == "env-secret-token" + + +def test_load_credentials_or_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test loading credentials from env vs token file.""" + # 1. Test env var set + monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") + res = load_credentials_or_env() + assert res == ("env", "env-tok") + + # 2. Test no file and no env var + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) + assert load_credentials_or_env() is None + + # 3. Test token file exists with Z expiry + token_data = { + "token": "file-tok", + "refresh_token": "re-tok", + "expiry": "2026-12-31T23:59:59Z", + } + token_file.write_text(json.dumps(token_data)) + res_file = load_credentials_or_env() + assert res_file is not None + assert res_file[0] == "file" + assert res_file[1].token == "file-tok" + + # 4. Token file with non-Z iso string with offset + token_data["expiry"] = "2026-12-31T23:59:59+00:00" + token_file.write_text(json.dumps(token_data)) + res_file2 = load_credentials_or_env() + assert res_file2 is not None + assert res_file2[0] == "file" + + +def test_cmd_login_missing_client_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test login failure when client secret file is missing.""" + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", + str(tmp_path / "nonexistent.json"), + ) + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "not found" in captured.out + + +def test_cmd_login_not_tty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test login failure in non-interactive environment.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"web": {}})) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "headless environment" in captured.out + + +def test_cmd_login_web_flow( + mock_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test login web OAuth flow.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text( + json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) + ) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_flow = MagicMock() + mock_flow_cls.from_client_secrets_file.return_value = mock_flow + mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.credentials = mock_creds + + # Empty response -> error + monkeypatch.setattr("builtins.input", lambda prompt="": "") + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + + # Valid auth code response + monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") + cmd_login(MagicMock()) + assert mock_flow.fetch_token.called + + +def test_cmd_login_installed_app_flow( + mock_installed_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test login installed app flow.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"installed": {}})) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_flow = MagicMock() + mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.run_local_server.return_value = mock_creds + + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "Logged in successfully" in captured.out + + +def test_unauthenticated_cli_setup( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test error when setup_client finds no auth token/credentials.""" + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", "non_existent_token.json" + ) + + with pytest.raises(SystemExit): + run_cli(["userinfo"]) + captured = capsys.readouterr() + assert "Not logged in" in captured.out diff --git a/tests/cli/subcommands/test_profile.py b/tests/cli/subcommands/test_profile.py new file mode 100644 index 0000000..42203ea --- /dev/null +++ b/tests/cli/subcommands/test_profile.py @@ -0,0 +1,69 @@ +"""Tests for profile CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_profile_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test profile CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + # profile get + mock_prof = MagicMock(spec=["to_dict"]) + mock_prof.to_dict.return_value = { + "name": "users/me/profile", + "displayName": "Alice", + } + mock_api.get_profile = AsyncMock(return_value=mock_prof) + + run_cli(["profile", "get"]) + captured = capsys.readouterr() + assert json.loads(captured.out)["displayName"] == "Alice" + + # profile update with json & update-mask & dry-run + payload = {"name": "users/me/profile", "displayName": "Bob"} + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "--json", + json.dumps(payload), + "--params", + json.dumps({"updateMask": "displayName"}), + "profile", + "update", + ] + ) + assert exit_info.value.code == 0 + captured = capsys.readouterr() + assert "dry_run" in json.loads(captured.out) + + # profile update execution + mock_api.update_profile = AsyncMock(return_value=mock_prof) + run_cli( + [ + "--json", + json.dumps(payload), + "profile", + "update", + "--update-mask", + "displayName", + ] + ) + mock_api.update_profile.assert_called_once() + + # profile update missing json + with pytest.raises(SystemExit): + run_cli(["profile", "update"]) + captured = capsys.readouterr() + assert "Please provide raw JSON input" in captured.out diff --git a/tests/cli/subcommands/test_settings.py b/tests/cli/subcommands/test_settings.py new file mode 100644 index 0000000..ae4f75b --- /dev/null +++ b/tests/cli/subcommands/test_settings.py @@ -0,0 +1,47 @@ +"""Tests for settings CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_settings_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test settings CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + # settings get + mock_sett = MagicMock(spec=["to_dict"]) + mock_sett.to_dict.return_value = {"name": "users/me/settings", "timeZone": "UTC"} + mock_api.get_settings = AsyncMock(return_value=mock_sett) + + run_cli(["settings", "get"]) + captured = capsys.readouterr() + assert json.loads(captured.out)["timeZone"] == "UTC" + + # settings update with json + mock_api.update_settings = AsyncMock(return_value=mock_sett) + payload = {"name": "users/me/settings", "timeZone": "America/New_York"} + run_cli( + [ + "--json", + json.dumps(payload), + "settings", + "update", + "--update-mask", + "timeZone", + ] + ) + mock_api.update_settings.assert_called_once() + + # settings update missing json + with pytest.raises(SystemExit): + run_cli(["settings", "update"]) diff --git a/tests/cli/subcommands/test_subscribers.py b/tests/cli/subcommands/test_subscribers.py new file mode 100644 index 0000000..01bb1bc --- /dev/null +++ b/tests/cli/subcommands/test_subscribers.py @@ -0,0 +1,110 @@ +"""Tests for subscribers CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_subscribers_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test subscribers CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + mock_sub_api = AsyncMock() + mock_api.subscribers = mock_sub_api + + # subscribers list + mock_sub_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscribers": []} + ) + run_cli(["subscribers", "list"]) + mock_sub_api.list.assert_called_once() + + # subscribers create with flags and dry-run + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "subscribers", + "create", + "--endpoint-uri", + "https://example.com/webhook", + "--endpoint-secret", + "secret123", + ] + ) + assert exit_info.value.code == 0 + + # subscribers create with json + payload = { + "name": "projects/me/subscribers/sub1", + "endpointUri": "https://example.com/webhook", + "endpointAuthorization": {"secret": "secret123"}, + "subscriberConfigs": [{"dataType": "steps"}], + } + mock_sub_api.create.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1"} + ) + run_cli( + [ + "--json", + json.dumps(payload), + "subscribers", + "create", + ] + ) + mock_sub_api.create.assert_called_once() + + # subscribers create missing endpointUri + with pytest.raises(SystemExit): + run_cli(["subscribers", "create"]) + + # subscribers patch + mock_sub_api.patch.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1"} + ) + sub_payload = { + "name": "projects/me/subscribers/sub1", + "endpointUri": "https://example.com/new", + "endpointAuthorization": {"secret": "secret123"}, + } + run_cli( + [ + "--json", + json.dumps(sub_payload), + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ] + ) + mock_sub_api.patch.assert_called_once() + + # subscribers patch missing json + with pytest.raises(SystemExit): + run_cli( + [ + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ] + ) + + # subscribers delete + mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) + run_cli( + [ + "subscribers", + "delete", + "projects/me/subscribers/sub1", + "--force", + ] + ) + mock_sub_api.delete.assert_called_once() diff --git a/tests/cli/subcommands/test_subscriptions.py b/tests/cli/subcommands/test_subscriptions.py new file mode 100644 index 0000000..8650fe2 --- /dev/null +++ b/tests/cli/subcommands/test_subscriptions.py @@ -0,0 +1,119 @@ +"""Tests for subscriptions CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_subscriptions_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test subscriptions CLI subcommands.""" + mock_load_credentials.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + mock_subscriptions_api = AsyncMock() + mock_api.subscribers.subscriptions = mock_subscriptions_api + + # subscriptions list + mock_subscriptions_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscriptions": []} + ) + run_cli( + [ + "subscriptions", + "list", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ] + ) + mock_subscriptions_api.list.assert_called_once() + + # subscriptions create with flags + mock_subscriptions_api.create.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1/subscriptions/s1"} + ) + run_cli( + [ + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + "--user", + "users/me", + "--data-types", + "steps", + ] + ) + mock_subscriptions_api.create.assert_called_once() + + # subscriptions create with json + payload = {"user": "users/me", "dataTypes": ["steps"]} + run_cli( + [ + "--json", + json.dumps(payload), + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ] + ) + + # subscriptions create missing user + with pytest.raises(SystemExit): + run_cli( + [ + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ] + ) + + # subscriptions patch + mock_subscriptions_api.patch.return_value = MagicMock( + spec=["to_dict"], to_dict=dict + ) + sub_patch_payload = { + "name": "projects/me/subscribers/sub1/subscriptions/s1", + "user": "users/me", + "dataTypes": ["steps", "heart-rate"], + } + run_cli( + [ + "--json", + json.dumps(sub_patch_payload), + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + + # subscriptions patch missing json + with pytest.raises(SystemExit): + run_cli( + [ + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + + # subscriptions delete + mock_subscriptions_api.delete.return_value = None + run_cli( + [ + "subscriptions", + "delete", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + captured = capsys.readouterr() + assert "Deleted subscription" in captured.out diff --git a/tests/cli/subcommands/test_userinfo.py b/tests/cli/subcommands/test_userinfo.py new file mode 100644 index 0000000..451d16f --- /dev/null +++ b/tests/cli/subcommands/test_userinfo.py @@ -0,0 +1,36 @@ +"""Tests for userinfo CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_userinfo( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that the userinfo command is correctly routed and executed in the CLI.""" + mock_load_credentials.return_value = ("env", "fake-token") + + mock_api = MagicMock() + mock_setup_client.return_value = mock_api + + mock_userinfo = MagicMock(spec=["to_dict"]) + mock_userinfo.to_dict.return_value = { + "sub": "110248495921238986420", + "name": "John Doe", + "email": "johndoe@example.com", + } + mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) + + run_cli(["userinfo"]) + + mock_api.get_user_info.assert_called_once() + captured = capsys.readouterr() + res_json = json.loads(captured.out) + assert res_json["sub"] == "110248495921238986420" + assert res_json["name"] == "John Doe" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index f0fa02d..b4f7c32 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,8 +1,6 @@ """Tests for the Google Health CLI.""" import json -import sys -from collections.abc import Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -10,51 +8,13 @@ from google_health_api.cli.commands import ( CliHealthSession, - CredentialsAuth, - EnvAuth, - cmd_login, fields_var, - load_credentials_or_env, serialize_response, ) -from google_health_api.cli.main import main from google_health_api.cli.validation import validate_resource_name, validate_safe_path from google_health_api.client import GoogleHealthSession from google_health_api.exceptions import HealthApiException - - -def run_cli(args: list[str]) -> None: - """Helper to run the CLI with specific arguments.""" - with patch.object(sys, "argv", ["google-health-cli", *args]): - main() - - -@pytest.fixture -def mock_load_credentials() -> Generator[MagicMock]: - """Fixture to mock load_credentials_or_env.""" - with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: - yield mock - - -@pytest.fixture -def mock_setup_client() -> Generator[MagicMock]: - """Fixture to mock setup_client.""" - with patch("google_health_api.cli.commands.setup_client") as mock: - yield mock - - -@pytest.fixture -def mock_flow_cls() -> Generator[MagicMock]: - """Fixture to mock google_auth_oauthlib.flow.Flow.""" - with patch("google_health_api.cli.commands.Flow") as mock: - yield mock - - -@pytest.fixture -def mock_installed_flow_cls() -> Generator[MagicMock]: - """Fixture to mock google_auth_oauthlib.flow.InstalledAppFlow.""" - with patch("google_health_api.cli.commands.InstalledAppFlow") as mock: - yield mock +from tests.cli.conftest import run_cli @pytest.mark.parametrize( @@ -314,43 +274,9 @@ def test_cli_weight_rollup( assert res_json["rollupDataPoints"][0]["weight"]["weightGramsAvg"] == 75000.0 -def test_cli_userinfo( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test that the userinfo command is correctly routed and executed in the CLI.""" - mock_load_credentials.return_value = ("env", "fake-token") - - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_userinfo = MagicMock(spec=["to_dict"]) - mock_userinfo.to_dict.return_value = { - "sub": "110248495921238986420", - "name": "John Doe", - "email": "johndoe@example.com", - } - mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) - - run_cli(["userinfo"]) - - mock_api.get_user_info.assert_called_once() - captured = capsys.readouterr() - res_json = json.loads(captured.out) - assert res_json["sub"] == "110248495921238986420" - assert res_json["name"] == "John Doe" - - -# ===================================================================== -# Additional Comprehensive CLI Tests -# ===================================================================== - - @pytest.mark.asyncio async def test_cli_health_session_fields_injection() -> None: """Test dynamic fields parameter injection in CliHealthSession.""" - auth_mock = AsyncMock() auth_mock.async_get_access_token = AsyncMock(return_value="fake-token") mock_session = AsyncMock() @@ -391,541 +317,6 @@ async def test_cli_health_session_fields_injection() -> None: fields_var.reset(token_val) -@pytest.mark.asyncio -async def test_credentials_auth_refresh( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test CredentialsAuth refreshing expired credentials.""" - - token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) - - creds_mock = MagicMock() - creds_mock.valid = False - creds_mock.token = "refreshed-token" - creds_mock.to_json.return_value = '{"token": "refreshed-token"}' - - session_mock = MagicMock() - auth = CredentialsAuth(session_mock, creds_mock) - - token = await auth.async_get_access_token() - assert token == "refreshed-token" - assert creds_mock.refresh.called - assert token_file.exists() - - -@pytest.mark.asyncio -async def test_env_auth() -> None: - """Test EnvAuth token retrieval.""" - - session_mock = MagicMock() - auth = EnvAuth(session_mock, "env-secret-token") - token = await auth.async_get_access_token() - assert token == "env-secret-token" - - -def test_load_credentials_or_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test loading credentials from env vs token file.""" - - # 1. Test env var set - monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") - res = load_credentials_or_env() - assert res == ("env", "env-tok") - - # 2. Test no file and no env var - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) - token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) - assert load_credentials_or_env() is None - - # 3. Test token file exists with Z expiry - token_data = { - "token": "file-tok", - "refresh_token": "re-tok", - "expiry": "2026-12-31T23:59:59Z", - } - token_file.write_text(json.dumps(token_data)) - res_file = load_credentials_or_env() - assert res_file is not None - assert res_file[0] == "file" - assert res_file[1].token == "file-tok" - - # 4. Token file with non-Z iso string with offset - token_data["expiry"] = "2026-12-31T23:59:59+00:00" - token_file.write_text(json.dumps(token_data)) - res_file2 = load_credentials_or_env() - assert res_file2 is not None - assert res_file2[0] == "file" - - -def test_cmd_login_missing_client_secret( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test login failure when client secret file is missing.""" - - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", - str(tmp_path / "nonexistent.json"), - ) - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "not found" in captured.out - - -def test_cmd_login_not_tty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test login failure in non-interactive environment.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text(json.dumps({"web": {}})) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: False) - - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "headless environment" in captured.out - - -def test_cmd_login_web_flow( - mock_flow_cls: MagicMock, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test login web OAuth flow.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text( - json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - - mock_flow = MagicMock() - mock_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") - mock_creds = MagicMock() - mock_creds.to_json.return_value = '{"token": "abc"}' - mock_flow.credentials = mock_creds - - # Empty response -> error - monkeypatch.setattr("builtins.input", lambda prompt="": "") - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - - # Valid auth code response - monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") - cmd_login(MagicMock()) - assert mock_flow.fetch_token.called - - -def test_cmd_login_installed_app_flow( - mock_installed_flow_cls: MagicMock, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test login installed app flow.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text(json.dumps({"installed": {}})) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - - mock_flow = MagicMock() - mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_creds = MagicMock() - mock_creds.to_json.return_value = '{"token": "abc"}' - mock_flow.run_local_server.return_value = mock_creds - - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "Logged in successfully" in captured.out - - -def test_cli_profile_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test profile CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - # profile get - mock_prof = MagicMock(spec=["to_dict"]) - mock_prof.to_dict.return_value = { - "name": "users/me/profile", - "displayName": "Alice", - } - mock_api.get_profile = AsyncMock(return_value=mock_prof) - - run_cli(["profile", "get"]) - captured = capsys.readouterr() - assert json.loads(captured.out)["displayName"] == "Alice" - - # profile update with json & update-mask & dry-run - payload = {"name": "users/me/profile", "displayName": "Bob"} - with pytest.raises(SystemExit) as exit_info: - run_cli( - [ - "--dry-run", - "--json", - json.dumps(payload), - "--params", - json.dumps({"updateMask": "displayName"}), - "profile", - "update", - ] - ) - assert exit_info.value.code == 0 - captured = capsys.readouterr() - assert "dry_run" in json.loads(captured.out) - - # profile update execution - mock_api.update_profile = AsyncMock(return_value=mock_prof) - run_cli( - [ - "--json", - json.dumps(payload), - "profile", - "update", - "--update-mask", - "displayName", - ] - ) - mock_api.update_profile.assert_called_once() - - # profile update missing json - with pytest.raises(SystemExit): - run_cli(["profile", "update"]) - captured = capsys.readouterr() - assert "Please provide raw JSON input" in captured.out - - -def test_cli_settings_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test settings CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - # settings get - mock_sett = MagicMock(spec=["to_dict"]) - mock_sett.to_dict.return_value = {"name": "users/me/settings", "timeZone": "UTC"} - mock_api.get_settings = AsyncMock(return_value=mock_sett) - - run_cli(["settings", "get"]) - captured = capsys.readouterr() - assert json.loads(captured.out)["timeZone"] == "UTC" - - # settings update with json - mock_api.update_settings = AsyncMock(return_value=mock_sett) - payload = {"name": "users/me/settings", "timeZone": "America/New_York"} - run_cli( - [ - "--json", - json.dumps(payload), - "settings", - "update", - "--update-mask", - "timeZone", - ] - ) - mock_api.update_settings.assert_called_once() - - # settings update missing json - with pytest.raises(SystemExit): - run_cli(["settings", "update"]) - - -def test_cli_devices_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test devices CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_devices_api = AsyncMock() - mock_api.paired_devices = mock_devices_api - - # devices list - mock_dev_res = MagicMock(spec=["to_dict"]) - mock_dev_res.to_dict.return_value = {"pairedDevices": []} - mock_devices_api.list.return_value = mock_dev_res - run_cli(["devices", "list"]) - mock_devices_api.list.assert_called_once() - capsys.readouterr() - - # devices get - mock_dev = MagicMock(spec=["to_dict"]) - mock_dev.to_dict.return_value = {"id": "dev123"} - mock_devices_api.get.return_value = mock_dev - run_cli(["devices", "get", "dev123"]) - captured = capsys.readouterr() - assert json.loads(captured.out)["id"] == "dev123" - - -def test_cli_subscribers_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test subscribers CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_sub_api = AsyncMock() - mock_api.subscribers = mock_sub_api - - # subscribers list - mock_sub_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscribers": []} - ) - run_cli(["subscribers", "list"]) - mock_sub_api.list.assert_called_once() - - # subscribers create with flags and dry-run - with pytest.raises(SystemExit) as exit_info: - run_cli( - [ - "--dry-run", - "subscribers", - "create", - "--endpoint-uri", - "https://example.com/webhook", - "--endpoint-secret", - "secret123", - ] - ) - assert exit_info.value.code == 0 - - # subscribers create with json - payload = { - "name": "projects/me/subscribers/sub1", - "endpointUri": "https://example.com/webhook", - "endpointAuthorization": {"secret": "secret123"}, - "subscriberConfigs": [{"dataType": "steps"}], - } - mock_sub_api.create.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1"} - ) - run_cli( - [ - "--json", - json.dumps(payload), - "subscribers", - "create", - ] - ) - mock_sub_api.create.assert_called_once() - - # subscribers create missing endpointUri - with pytest.raises(SystemExit): - run_cli(["subscribers", "create"]) - - # subscribers patch - mock_sub_api.patch.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1"} - ) - sub_payload = { - "name": "projects/me/subscribers/sub1", - "endpointUri": "https://example.com/new", - "endpointAuthorization": {"secret": "secret123"}, - } - run_cli( - [ - "--json", - json.dumps(sub_payload), - "subscribers", - "patch", - "projects/me/subscribers/sub1", - ] - ) - mock_sub_api.patch.assert_called_once() - - # subscribers patch missing json - with pytest.raises(SystemExit): - run_cli( - [ - "subscribers", - "patch", - "projects/me/subscribers/sub1", - ] - ) - - # subscribers delete - mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) - run_cli( - [ - "subscribers", - "delete", - "projects/me/subscribers/sub1", - "--force", - ] - ) - mock_sub_api.delete.assert_called_once() - - -def test_cli_subscriptions_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test subscriptions CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_subscriptions_api = AsyncMock() - mock_api.subscribers.subscriptions = mock_subscriptions_api - - # subscriptions list - mock_subscriptions_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscriptions": []} - ) - run_cli( - [ - "subscriptions", - "list", - "--parent-subscriber", - "projects/me/subscribers/sub1", - ] - ) - mock_subscriptions_api.list.assert_called_once() - - # subscriptions create with flags - mock_subscriptions_api.create.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1/subscriptions/s1"} - ) - run_cli( - [ - "subscriptions", - "create", - "--parent-subscriber", - "projects/me/subscribers/sub1", - "--user", - "users/me", - "--data-types", - "steps", - ] - ) - mock_subscriptions_api.create.assert_called_once() - - # subscriptions create with json - payload = {"user": "users/me", "dataTypes": ["steps"]} - run_cli( - [ - "--json", - json.dumps(payload), - "subscriptions", - "create", - "--parent-subscriber", - "projects/me/subscribers/sub1", - ] - ) - - # subscriptions create missing user - with pytest.raises(SystemExit): - run_cli( - [ - "subscriptions", - "create", - "--parent-subscriber", - "projects/me/subscribers/sub1", - ] - ) - - # subscriptions patch - mock_subscriptions_api.patch.return_value = MagicMock( - spec=["to_dict"], to_dict=dict - ) - sub_patch_payload = { - "name": "projects/me/subscribers/sub1/subscriptions/s1", - "user": "users/me", - "dataTypes": ["steps", "heart-rate"], - } - run_cli( - [ - "--json", - json.dumps(sub_patch_payload), - "subscriptions", - "patch", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - - # subscriptions patch missing json - with pytest.raises(SystemExit): - run_cli( - [ - "subscriptions", - "patch", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - - # subscriptions delete - mock_subscriptions_api.delete.return_value = None - run_cli( - [ - "subscriptions", - "delete", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - captured = capsys.readouterr() - assert "Deleted subscription" in captured.out - - -def test_cli_identity_and_irn( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test identity and irn CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_api.get_identity = AsyncMock( - return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"subject": "user123"}) - ) - run_cli(["identity", "get"]) - mock_api.get_identity.assert_called_once() - - mock_api.get_irn_profile = AsyncMock( - return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) - ) - run_cli(["irn", "get"]) - mock_api.get_irn_profile.assert_called_once() - - def test_cli_additional_datatypes( mock_load_credentials: MagicMock, mock_setup_client: MagicMock, @@ -1162,7 +553,6 @@ async def async_iter_subscription(): def test_serialize_response_reconciled_datapoints() -> None: """Test serialize_response for reconciled data points and generic objects.""" - dp = MagicMock() dp.name = "p1" dp.data_source = None @@ -1189,7 +579,6 @@ def test_cli_exceptions_handling( capsys: pytest.CaptureFixture[str], ) -> None: """Test HealthApiException and general Exception handling in async_run_cmd.""" - mock_load_credentials.return_value = ("env", "fake-token") # HealthApiException @@ -1241,18 +630,3 @@ def test_cli_invalid_json_payloads( ) captured = capsys.readouterr() assert "Invalid --params JSON payload" in captured.out - - -def test_unauthenticated_cli_setup( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test error when setup_client finds no auth token/credentials.""" - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", "non_existent_token.json" - ) - - with pytest.raises(SystemExit): - run_cli(["userinfo"]) - captured = capsys.readouterr() - assert "Not logged in" in captured.out