Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
eccb9ca
Reapply "test: Improve test coverage for HTTP client error handling (…
allenporter Jul 26, 2026
396e14d
Reapply "test: Improve test coverage for CLI schema introspection (#4…
allenporter Jul 26, 2026
b9d22d8
test: Rename test_schema.py to test_cli_schema.py to mirror source la…
allenporter Jul 26, 2026
9ad74c9
test: Improve test coverage for pagination async iteration
allenporter Jul 21, 2026
679c4d3
test: Add tests/test_model_pagination.py for pagination coverage
allenporter Jul 26, 2026
3d3e8ad
test: Improve test coverage for CLI commands
allenporter Jul 21, 2026
9e3a8ee
test: Move test files into tests/cli and tests/model to mirror source…
allenporter Jul 26, 2026
7420359
test: Parameterize test_validate_resource_name in test_cli.py
allenporter Jul 26, 2026
fe0a3f8
test: Move local imports to top level in test_cli.py
allenporter Jul 26, 2026
7b7d41a
refactor: Expose public data_type property on DataPointSubApi and upd…
allenporter Jul 26, 2026
e56d828
test: Parameterize test_error_detail_variations in test_client.py
allenporter Jul 26, 2026
3f10b3d
refactor: Convert _error_detail to a module-level function
allenporter Jul 26, 2026
6ef1ac5
refactor: Expose error_detail as a public module-level function and r…
allenporter Jul 26, 2026
20c6035
refactor: Add __all__ export list to client.py
allenporter Jul 26, 2026
cb375da
Revert "refactor: Add __all__ export list to client.py"
allenporter Jul 26, 2026
9a6d1d9
refactor: Revert _error_detail back to private function to keep it ou…
allenporter Jul 26, 2026
0c121f0
Revert "refactor: Revert _error_detail back to private function to ke…
allenporter Jul 26, 2026
c4cdd3d
merge: Merge remote-tracking branch origin/main into improve-test-cod…
allenporter Jul 26, 2026
b559002
refactor: Use pathlib.Path instead of os in validation code and tests
allenporter Jul 26, 2026
1bb58a2
refactor: Split schema tests, add run_cli helper, combine withs, use …
allenporter Jul 26, 2026
865ebec
refactor: Move CLI schema command tests from test_cli.py to test_sche…
allenporter Jul 26, 2026
d1f2bfe
refactor: Split huge CLI commands and tests codebase into submodules
allenporter Jul 26, 2026
b4aaa02
Merge branch 'main' into improve-test-code-coverage
allenporter Jul 26, 2026
bb1f006
chore: Apply Ruff formatting and import fixes to CLI submodules and t…
allenporter Jul 26, 2026
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
131 changes: 131 additions & 0 deletions google_health_api/cli/auth.py
Original file line number Diff line number Diff line change
@@ -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)
Loading