Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions google_health_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,9 +1258,15 @@ class GoogleHealthApi:
operations: OperationsSubApi
"""Namespaced client for retrieving and waiting on long-running operations."""

def __init__(self, auth: AbstractAuth) -> None:
def __init__(
self,
auth: AbstractAuth,
session: GoogleHealthSession | None = None,
) -> None:
"""Initialize the client."""
self._session = GoogleHealthSession(auth, auth._websession, auth._host)
self._session = session or GoogleHealthSession(
auth, auth._websession, auth._host
)
self.steps = RollupDataPointSubApi(self._session, STEPS)
self.heart_rate = RollupDataPointSubApi(self._session, HEART_RATE)
self.sleep = DataPointSubApi(self._session, SLEEP)
Expand Down Expand Up @@ -1428,6 +1434,6 @@ async def get_irn_profile(self, user: str = "me") -> IrnProfile:

async def get_user_info(self) -> UserInfo:
"""Retrieve the authenticated user's Google OAuth2 userinfo."""
resp = await self._session.get("https://www.googleapis.com/oauth2/v3/userinfo")
resp = await self._session.get(self._session.user_info_url)
raw_json = await resp.json()
return UserInfo.from_dict(raw_json)
41 changes: 7 additions & 34 deletions google_health_api/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,40 +168,13 @@ async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi:
else:
auth = CredentialsAuth(session, auth_data[1], host=host)

api = GoogleHealthApi(auth)
# Inject CliHealthSession
api._session = CliHealthSession(auth, session, host)
# Update nested api classes with the new session
api.steps._session = api._session
api.heart_rate._session = api._session
api.sleep._session = api._session
api.distance._session = api._session
api.basal_energy_burned._session = api._session
api.vo2_max._session = api._session
api.weight._session = api._session
api.height._session = api._session
api.oxygen_saturation._session = api._session
api.daily_oxygen_saturation._session = api._session
api.exercise._session = api._session
api.electrocardiogram._session = api._session
api.irregular_rhythm_notification._session = api._session
api.daily_vo2_max._session = api._session
api.daily_heart_rate_zones._session = api._session
api.daily_sleep_temperature_derivations._session = api._session
api.daily_respiratory_rate._session = api._session
api.respiratory_rate_sleep_summary._session = api._session
api.active_energy_burned._session = api._session
api.total_calories._session = api._session
api.floors._session = api._session
api.hydration_log._session = api._session
api.daily_resting_heart_rate._session = api._session
api.heart_rate_variability._session = api._session
api.daily_heart_rate_variability._session = api._session
api.nutrition_log._session = api._session
api.paired_devices._session = api._session
api.subscribers._session = api._session
api.subscribers.subscriptions._session = api._session
return api
cli_session = CliHealthSession(
auth,
session,
host,
user_info_url=os.environ.get("GOOGLE_HEALTH_USERINFO_URL"),
)
return GoogleHealthApi(auth, session=cli_session)


async def handle_datatype_cmd(
Expand Down
9 changes: 7 additions & 2 deletions google_health_api/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ def add_standard_datapoint_commands(
)


def main() -> None:
"""CLI parser setup and subcommand routing."""
def build_parser() -> argparse.ArgumentParser:
"""Build the command line argument parser."""
parser = argparse.ArgumentParser(
description="Google Health API CLI tool revamped with Agent DX principles."
)
Expand Down Expand Up @@ -517,7 +517,12 @@ def main() -> None:
subscriptions_delete.add_argument(
"name", type=str, help="The subscription resource name"
)
return parser


def main() -> None:
"""CLI entrypoint and subcommand routing."""
parser = build_parser()
args = parser.parse_args()

if args.command == "login":
Expand Down
4 changes: 4 additions & 0 deletions google_health_api/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys
from typing import Any, NoReturn

from google_health_api.api import PendingOperation
from google_health_api.model import DataPoint, ReconciledDataPoint


Expand Down Expand Up @@ -50,6 +51,9 @@ def serialize_reconciled_datapoint(

def serialize_response(result: Any, field_name: str | None = None) -> Any:
"""Convert API response object/paginated result to JSON-serializable structure."""
if isinstance(result, PendingOperation):
result = result.operation

if hasattr(result, "to_dict"):
return result.to_dict()
if hasattr(result, "data_points"):
Expand Down
4 changes: 3 additions & 1 deletion google_health_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from aiohttp.client_exceptions import ClientError

from .auth import AbstractAuth
from .const import HEALTH_API_URL
from .const import HEALTH_API_URL, USERINFO_API_URL
from .exceptions import (
HealthApiException,
HealthApiForbiddenException,
Expand All @@ -28,12 +28,14 @@ def __init__(
auth: AbstractAuth,
websession: aiohttp.ClientSession,
host: str | None = None,
user_info_url: str | None = None,
) -> None:
"""Initialize the session."""
self._auth = auth
self._websession = websession
self._host = host or HEALTH_API_URL
self._timezone_cache: dict[str, str] = {}
self.user_info_url = user_info_url or USERINFO_API_URL

async def request(
self,
Expand Down
1 change: 1 addition & 0 deletions google_health_api/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Constants for the Google Health API."""

HEALTH_API_URL = "https://health.googleapis.com"
USERINFO_API_URL = "https://www.googleapis.com/oauth2/v3/userinfo"


class HealthApiScope:
Expand Down
54 changes: 52 additions & 2 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""Shared pytest fixtures for CLI tests."""

import os
import sys
from collections.abc import Generator
from collections.abc import Awaitable, Callable, Generator
from typing import Any
from unittest.mock import MagicMock, patch

import aiohttp
import pytest

from google_health_api.cli.main import main
from google_health_api.cli.commands import async_run_cmd
from google_health_api.cli.main import build_parser, main
from tests.conftest import PATH_PREFIX, AuthCallback


def run_cli(args: list[str]) -> None:
Expand All @@ -15,6 +20,13 @@ def run_cli(args: list[str]) -> None:
main()


async def async_run_cli(args: list[str]) -> None:
"""Helper to run the CLI asynchronously within a running loop."""
parser = build_parser()
parsed_args = parser.parse_args(args)
await async_run_cmd(parsed_args)


@pytest.fixture
def mock_load_credentials() -> Generator[MagicMock]:
"""Fixture to mock load_credentials_or_env."""
Expand All @@ -41,3 +53,41 @@ 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


@pytest.fixture
def run_cli_against_server(
auth_cb: AuthCallback,
) -> Callable[[list[str], list[tuple[str, str, Any]]], Awaitable[None]]:
"""Fixture to run the CLI against a mock server with register endpoints."""

async def run(
args: list[str],
handlers: list[
tuple[
str,
str,
Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]],
]
],
) -> None:
auth = await auth_cb(handlers)
server_url = str(auth._websession.make_url(PATH_PREFIX))

# Direct setup_client to the mock server and stub credentials
with (
patch.dict(
os.environ,
{
"GOOGLE_HEALTH_API_URL": server_url,
"GOOGLE_HEALTH_USERINFO_URL": f"{server_url}/oauth2/v3/userinfo",
},
),
patch(
"google_health_api.cli.commands.load_credentials_or_env",
return_value=("env", "fake-token"),
),
):
await async_run_cli(args)

return run
60 changes: 33 additions & 27 deletions tests/cli/subcommands/test_devices.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,44 @@
"""Tests for devices CLI command."""

import json
from unittest.mock import AsyncMock, MagicMock

import aiohttp
import pytest

from tests.cli.conftest import run_cli


def test_cli_devices_commands(
mock_load_credentials: MagicMock,
mock_setup_client: MagicMock,
@pytest.mark.asyncio
async def test_cli_devices_commands(
run_cli_against_server,
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"])
device_data = {
"name": "projects/me/pairedDevices/dev123",
"deviceType": "WATCH",
}

async def list_devices_handler(
request: aiohttp.web.Request,
) -> aiohttp.web.Response:
return aiohttp.web.json_response({"pairedDevices": [device_data]})

async def get_device_handler(
request: aiohttp.web.Request,
) -> aiohttp.web.Response:
return aiohttp.web.json_response(device_data)

# test devices list
await run_cli_against_server(
["devices", "list"],
[("GET", "v4/users/me/pairedDevices", list_devices_handler)],
)
captured = capsys.readouterr()
assert "WATCH" in captured.out

# test devices get
await run_cli_against_server(
["devices", "get", "dev123"],
[("GET", "v4/users/me/pairedDevices/dev123", get_device_handler)],
)
captured = capsys.readouterr()
assert json.loads(captured.out)["id"] == "dev123"
assert json.loads(captured.out)["name"] == "projects/me/pairedDevices/dev123"
48 changes: 31 additions & 17 deletions tests/cli/subcommands/test_identity.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
"""Tests for identity and irn CLI commands."""

from unittest.mock import AsyncMock, MagicMock
import json

import aiohttp
import pytest

from tests.cli.conftest import run_cli


def test_cli_identity_and_irn(
mock_load_credentials: MagicMock,
mock_setup_client: MagicMock,
@pytest.mark.asyncio
async def test_cli_identity_and_irn(
run_cli_against_server,
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"})
async def get_identity_handler(
request: aiohttp.web.Request,
) -> aiohttp.web.Response:
return aiohttp.web.json_response(
{"name": "users/me/identity", "healthUserId": "user123"}
)

async def get_irn_handler(
request: aiohttp.web.Request,
) -> aiohttp.web.Response:
return aiohttp.web.json_response(
{"name": "users/me/irnProfile", "onboardingStatus": True}
)

# test identity get
await run_cli_against_server(
["identity", "get"],
[("GET", "v4/users/me/identity", get_identity_handler)],
)
run_cli(["identity", "get"])
mock_api.get_identity.assert_called_once()
captured = capsys.readouterr()
assert json.loads(captured.out)["healthUserId"] == "user123"

mock_api.get_irn_profile = AsyncMock(
return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"})
# test irn get
await run_cli_against_server(
["irn", "get"],
[("GET", "v4/users/me/irnProfile", get_irn_handler)],
)
run_cli(["irn", "get"])
mock_api.get_irn_profile.assert_called_once()
captured = capsys.readouterr()
assert json.loads(captured.out)["onboardingStatus"] is True
Loading