google-analytics-cli 0.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. ga_cli/__init__.py +8 -0
  2. ga_cli/api/__init__.py +0 -0
  3. ga_cli/api/client.py +142 -0
  4. ga_cli/auth/__init__.py +35 -0
  5. ga_cli/auth/credentials.py +126 -0
  6. ga_cli/auth/oauth.py +322 -0
  7. ga_cli/auth/service_account.py +155 -0
  8. ga_cli/commands/__init__.py +0 -0
  9. ga_cli/commands/access_bindings.py +254 -0
  10. ga_cli/commands/access_reports.py +201 -0
  11. ga_cli/commands/account_summaries.py +68 -0
  12. ga_cli/commands/accounts.py +297 -0
  13. ga_cli/commands/agent_cmd.py +776 -0
  14. ga_cli/commands/annotations.py +264 -0
  15. ga_cli/commands/audiences.py +223 -0
  16. ga_cli/commands/auth_cmd.py +205 -0
  17. ga_cli/commands/bigquery_links.py +309 -0
  18. ga_cli/commands/calculated_metrics.py +312 -0
  19. ga_cli/commands/channel_groups.py +223 -0
  20. ga_cli/commands/completions_cmd.py +55 -0
  21. ga_cli/commands/config_cmd.py +113 -0
  22. ga_cli/commands/custom_dimensions.py +272 -0
  23. ga_cli/commands/custom_metrics.py +305 -0
  24. ga_cli/commands/data_retention.py +153 -0
  25. ga_cli/commands/data_streams.py +277 -0
  26. ga_cli/commands/event_create_rules.py +250 -0
  27. ga_cli/commands/event_edit_rules.py +292 -0
  28. ga_cli/commands/firebase_links.py +142 -0
  29. ga_cli/commands/google_ads_links.py +225 -0
  30. ga_cli/commands/key_events.py +269 -0
  31. ga_cli/commands/mp_secrets.py +265 -0
  32. ga_cli/commands/properties.py +330 -0
  33. ga_cli/commands/property_settings.py +287 -0
  34. ga_cli/commands/reports.py +726 -0
  35. ga_cli/commands/upgrade_cmd.py +148 -0
  36. ga_cli/config/__init__.py +0 -0
  37. ga_cli/config/constants.py +61 -0
  38. ga_cli/config/store.py +115 -0
  39. ga_cli/main.py +110 -0
  40. ga_cli/utils/__init__.py +20 -0
  41. ga_cli/utils/describe.py +129 -0
  42. ga_cli/utils/dry_run.py +40 -0
  43. ga_cli/utils/errors.py +150 -0
  44. ga_cli/utils/output.py +209 -0
  45. ga_cli/utils/pagination.py +93 -0
  46. google_analytics_cli-0.1.0rc1.dist-info/METADATA +269 -0
  47. google_analytics_cli-0.1.0rc1.dist-info/RECORD +49 -0
  48. google_analytics_cli-0.1.0rc1.dist-info/WHEEL +4 -0
  49. google_analytics_cli-0.1.0rc1.dist-info/entry_points.txt +2 -0
ga_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """GA CLI — Command-line interface for Google Analytics 4."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("ga-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
ga_cli/api/__init__.py ADDED
File without changes
ga_cli/api/client.py ADDED
@@ -0,0 +1,142 @@
1
+ """Google Analytics API client wrappers.
2
+
3
+ Provides authenticated clients for the Analytics Admin API and Data API.
4
+ Uses google-api-python-client (REST-based), consistent with GTM CLI's use
5
+ of the googleapis npm package.
6
+
7
+ Authentication priority:
8
+ 1. Service account (env var or saved method)
9
+ 2. OAuth (stored credentials)
10
+
11
+ Equivalent to GTM CLI's api/client.ts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional
17
+
18
+ from google.oauth2.credentials import Credentials
19
+ from googleapiclient.discovery import Resource, build
20
+
21
+ from ..auth.credentials import get_valid_credentials
22
+ from ..auth.service_account import get_service_account_credentials
23
+
24
+ # Cached client instances (same pattern as GTM CLI)
25
+ _cached_admin_client: Optional[Resource] = None
26
+ _cached_admin_alpha_client: Optional[Resource] = None
27
+ _cached_data_client: Optional[Resource] = None
28
+ _cached_data_alpha_client: Optional[Resource] = None
29
+
30
+
31
+ def _get_credentials() -> Credentials:
32
+ """Get valid credentials (service account or OAuth).
33
+
34
+ Priority: service account > OAuth.
35
+ Raises if no authentication is configured.
36
+ """
37
+ # Try service account first
38
+ sa_creds = get_service_account_credentials()
39
+ if sa_creds is not None:
40
+ from google.auth.transport.requests import Request
41
+ if sa_creds.expired:
42
+ sa_creds.refresh(Request())
43
+ return sa_creds
44
+
45
+ # Fall back to OAuth
46
+ oauth_creds = get_valid_credentials()
47
+ if oauth_creds is not None:
48
+ return oauth_creds
49
+
50
+ raise RuntimeError(
51
+ "Not authenticated. Run 'ga auth login' or configure a service account."
52
+ )
53
+
54
+
55
+ def get_admin_client() -> Resource:
56
+ """Get an authenticated Analytics Admin API client.
57
+
58
+ Returns a googleapiclient Resource for analyticsadmin v1beta.
59
+ Uses caching to avoid rebuilding the client on every call.
60
+
61
+ Usage:
62
+ admin = get_admin_client()
63
+ result = admin.accounts().list().execute()
64
+ """
65
+ global _cached_admin_client
66
+ if _cached_admin_client is None:
67
+ creds = _get_credentials()
68
+ _cached_admin_client = build(
69
+ "analyticsadmin",
70
+ "v1beta",
71
+ credentials=creds,
72
+ )
73
+ return _cached_admin_client
74
+
75
+
76
+ def get_admin_alpha_client() -> Resource:
77
+ """Get an authenticated Analytics Admin API client (v1alpha).
78
+
79
+ Returns a googleapiclient Resource for analyticsadmin v1alpha.
80
+ Used for alpha-only resources: audiences, BigQuery links, channel groups,
81
+ calculated metrics, event rules, access bindings, annotations, etc.
82
+ """
83
+ global _cached_admin_alpha_client
84
+ if _cached_admin_alpha_client is None:
85
+ creds = _get_credentials()
86
+ _cached_admin_alpha_client = build(
87
+ "analyticsadmin",
88
+ "v1alpha",
89
+ credentials=creds,
90
+ )
91
+ return _cached_admin_alpha_client
92
+
93
+
94
+ def get_data_client() -> Resource:
95
+ """Get an authenticated Analytics Data API client.
96
+
97
+ Returns a googleapiclient Resource for analyticsdata v1beta.
98
+
99
+ Usage:
100
+ data = get_data_client()
101
+ result = data.properties().runReport(
102
+ property="properties/12345",
103
+ body={...},
104
+ ).execute()
105
+ """
106
+ global _cached_data_client
107
+ if _cached_data_client is None:
108
+ creds = _get_credentials()
109
+ _cached_data_client = build(
110
+ "analyticsdata",
111
+ "v1beta",
112
+ credentials=creds,
113
+ )
114
+ return _cached_data_client
115
+
116
+
117
+ def get_data_alpha_client() -> Resource:
118
+ """Get an authenticated Analytics Data API client (v1alpha).
119
+
120
+ Returns a googleapiclient Resource for analyticsdata v1alpha.
121
+ Used for alpha-only methods: funnel reports, property quotas snapshot.
122
+ """
123
+ global _cached_data_alpha_client
124
+ if _cached_data_alpha_client is None:
125
+ creds = _get_credentials()
126
+ _cached_data_alpha_client = build(
127
+ "analyticsdata",
128
+ "v1alpha",
129
+ credentials=creds,
130
+ static_discovery=False,
131
+ )
132
+ return _cached_data_alpha_client
133
+
134
+
135
+ def clear_client_cache() -> None:
136
+ """Clear cached API clients (e.g., after re-authentication)."""
137
+ global _cached_admin_client, _cached_admin_alpha_client
138
+ global _cached_data_client, _cached_data_alpha_client
139
+ _cached_admin_client = None
140
+ _cached_admin_alpha_client = None
141
+ _cached_data_client = None
142
+ _cached_data_alpha_client = None
@@ -0,0 +1,35 @@
1
+ """Authentication module for GA CLI.
2
+
3
+ Public API for OAuth and service account authentication.
4
+ """
5
+
6
+ from .credentials import (
7
+ delete_credentials,
8
+ get_valid_credentials,
9
+ has_credentials,
10
+ load_credentials,
11
+ save_credentials,
12
+ )
13
+ from .oauth import get_auth_status, login, logout
14
+ from .service_account import (
15
+ get_service_account_credentials,
16
+ load_auth_method,
17
+ login_with_service_account,
18
+ )
19
+
20
+ __all__ = [
21
+ # OAuth flow
22
+ "login",
23
+ "logout",
24
+ "get_auth_status",
25
+ # Credentials
26
+ "get_valid_credentials",
27
+ "load_credentials",
28
+ "save_credentials",
29
+ "delete_credentials",
30
+ "has_credentials",
31
+ # Service account
32
+ "login_with_service_account",
33
+ "get_service_account_credentials",
34
+ "load_auth_method",
35
+ ]
@@ -0,0 +1,126 @@
1
+ """Credential storage and management.
2
+
3
+ Stores OAuth tokens at ~/.config/ga-cli/credentials.json with
4
+ restrictive permissions (0o600 on Unix).
5
+
6
+ Equivalent to GTM CLI's auth/credentials.ts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import platform
15
+ from datetime import datetime
16
+ from typing import Optional
17
+
18
+ from google.oauth2.credentials import Credentials
19
+
20
+ from ..config.constants import (
21
+ OAUTH_SCOPES,
22
+ get_config_dir,
23
+ get_credentials_path,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def save_credentials(credentials: Credentials) -> None:
30
+ """Save OAuth credentials to disk.
31
+
32
+ Stores the token data as JSON with 0o600 permissions on Unix.
33
+ """
34
+ config_dir = get_config_dir()
35
+ config_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ creds_path = get_credentials_path()
38
+
39
+ data = {
40
+ "token": credentials.token,
41
+ "refresh_token": credentials.refresh_token,
42
+ "token_uri": credentials.token_uri,
43
+ "client_id": credentials.client_id,
44
+ "client_secret": credentials.client_secret,
45
+ "scopes": list(credentials.scopes) if credentials.scopes else OAUTH_SCOPES,
46
+ "expiry": credentials.expiry.isoformat() if credentials.expiry else None,
47
+ }
48
+
49
+ creds_path.write_text(json.dumps(data, indent=2))
50
+
51
+ # Set restrictive permissions on Unix
52
+ if platform.system() != "Windows":
53
+ os.chmod(creds_path, 0o600)
54
+
55
+
56
+ def load_credentials() -> Optional[Credentials]:
57
+ """Load OAuth credentials from disk.
58
+
59
+ Returns None if no credentials file exists or if the file is corrupt.
60
+ """
61
+ creds_path = get_credentials_path()
62
+
63
+ if not creds_path.exists():
64
+ return None
65
+
66
+ try:
67
+ data = json.loads(creds_path.read_text())
68
+ except (json.JSONDecodeError, OSError) as exc:
69
+ logger.warning("Failed to read credentials file: %s", exc)
70
+ return None
71
+
72
+ creds = Credentials(
73
+ token=data.get("token"),
74
+ refresh_token=data.get("refresh_token"),
75
+ token_uri=data.get("token_uri", "https://oauth2.googleapis.com/token"),
76
+ client_id=data.get("client_id"),
77
+ client_secret=data.get("client_secret"),
78
+ scopes=data.get("scopes", OAUTH_SCOPES),
79
+ )
80
+
81
+ if data.get("expiry"):
82
+ try:
83
+ expiry = datetime.fromisoformat(data["expiry"])
84
+ # google-auth expects expiry without tzinfo (assumes UTC internally)
85
+ creds.expiry = expiry.replace(tzinfo=None)
86
+ except ValueError:
87
+ logger.warning("Invalid expiry timestamp in credentials file")
88
+
89
+ return creds
90
+
91
+
92
+ def delete_credentials() -> None:
93
+ """Delete stored credentials file."""
94
+ creds_path = get_credentials_path()
95
+ try:
96
+ creds_path.unlink()
97
+ except FileNotFoundError:
98
+ pass
99
+
100
+
101
+ def has_credentials() -> bool:
102
+ """Check if credentials file exists."""
103
+ return get_credentials_path().exists()
104
+
105
+
106
+ def get_valid_credentials() -> Optional[Credentials]:
107
+ """Load credentials and refresh if expired.
108
+
109
+ This is the main entry point for getting a usable token.
110
+ Returns None if no credentials are stored.
111
+ """
112
+ creds = load_credentials()
113
+ if creds is None:
114
+ return None
115
+
116
+ if creds.expired and creds.refresh_token:
117
+ from google.auth.transport.requests import Request
118
+
119
+ try:
120
+ creds.refresh(Request())
121
+ save_credentials(creds)
122
+ except Exception as exc:
123
+ logger.warning("Failed to refresh token: %s", exc)
124
+ return None
125
+
126
+ return creds
ga_cli/auth/oauth.py ADDED
@@ -0,0 +1,322 @@
1
+ """OAuth 2.0 authentication flow.
2
+
3
+ Uses google-auth-oauthlib's InstalledAppFlow which handles:
4
+ - Local HTTP server for the callback
5
+ - Browser opening
6
+ - CSRF state parameter
7
+ - Authorization code exchange
8
+ - Token retrieval
9
+
10
+ This replaces ~300 lines of manual OAuth code in the GTM CLI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import webbrowser
18
+ import wsgiref.simple_server
19
+ import wsgiref.util
20
+ from typing import Optional
21
+
22
+ import requests
23
+ import typer
24
+ from google.oauth2.credentials import Credentials
25
+ from google_auth_oauthlib.flow import InstalledAppFlow
26
+ from rich.panel import Panel
27
+
28
+ from ..config.constants import (
29
+ OAUTH_CALLBACK_PORT,
30
+ OAUTH_SCOPES,
31
+ get_client_secret_path,
32
+ )
33
+ from .credentials import (
34
+ delete_credentials,
35
+ get_valid_credentials,
36
+ load_credentials,
37
+ save_credentials,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ _SUCCESS_HTML = """\
43
+ <!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
48
+ <title>GA CLI – Authentication Successful</title>
49
+ <style>
50
+ * { margin: 0; padding: 0; box-sizing: border-box; }
51
+ body {
52
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
53
+ Helvetica, Arial, sans-serif;
54
+ background: #f8f9fa;
55
+ color: #1a1a2e;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ min-height: 100vh;
60
+ }
61
+ .card {
62
+ background: #fff;
63
+ border-radius: 16px;
64
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
65
+ padding: 48px 40px;
66
+ text-align: center;
67
+ max-width: 420px;
68
+ width: 100%;
69
+ }
70
+ .icon {
71
+ width: 80px; height: 80px;
72
+ background: #235495;
73
+ border-radius: 50%;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ margin: 0 auto 20px;
78
+ }
79
+ .icon svg { width: 40px; height: 40px; }
80
+ .brand {
81
+ font-size: 14px;
82
+ font-weight: 600;
83
+ color: #dd973a;
84
+ letter-spacing: 0.5px;
85
+ margin-bottom: 8px;
86
+ }
87
+ h1 {
88
+ font-size: 24px;
89
+ font-weight: 700;
90
+ margin-bottom: 8px;
91
+ }
92
+ .subtitle {
93
+ font-size: 15px;
94
+ color: #666;
95
+ margin-bottom: 28px;
96
+ }
97
+ .command {
98
+ display: inline-block;
99
+ background: #f4f4f5;
100
+ border: 1px solid #e4e4e7;
101
+ border-radius: 8px;
102
+ padding: 10px 20px;
103
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
104
+ font-size: 14px;
105
+ color: #1a1a2e;
106
+ }
107
+ .footer {
108
+ margin-top: 32px;
109
+ font-size: 12px;
110
+ color: #aaa;
111
+ }
112
+ .footer a { color: #dd973a; text-decoration: none; }
113
+ .footer a:hover { text-decoration: underline; }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div class="card">
118
+ <div class="icon">
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.5"
120
+ stroke-linecap="round" stroke-linejoin="round">
121
+ <path d="M20 6L9 17l-5-5"/>
122
+ </svg>
123
+ </div>
124
+ <div class="brand">GA CLI</div>
125
+ <h1>Authentication Successful</h1>
126
+ <p class="subtitle">
127
+ You're all set! You can close this window and<br>return to your terminal.
128
+ </p>
129
+ <div class="command">ga accounts list</div>
130
+ <div class="footer">
131
+ Powered by <a href="https://github.com/daidalytics/google-analytics-cli"
132
+ target="_blank" rel="noopener">ga-cli</a>
133
+ </div>
134
+ </div>
135
+ </body>
136
+ </html>
137
+ """
138
+
139
+
140
+ class _HtmlRedirectWSGIApp:
141
+ """WSGI app that serves a styled HTML success page on OAuth callback."""
142
+
143
+ def __init__(self, html: str):
144
+ self.last_request_uri: Optional[str] = None
145
+ self._html = html
146
+
147
+ def __call__(self, environ, start_response):
148
+ start_response(
149
+ "200 OK",
150
+ [("Content-Type", "text/html; charset=utf-8")],
151
+ )
152
+ self.last_request_uri = wsgiref.util.request_uri(environ)
153
+ return [self._html.encode("utf-8")]
154
+
155
+
156
+ class _SilentRequestHandler(wsgiref.simple_server.WSGIRequestHandler):
157
+ """Request handler that logs to the logger instead of stderr."""
158
+
159
+ def log_message(self, format, *args): # noqa: A002
160
+ logger.debug(format, *args)
161
+
162
+
163
+ def _get_client_config() -> dict:
164
+ """Build the OAuth client config dict from env vars."""
165
+ client_id = os.environ.get("GA_CLI_CLIENT_ID", "")
166
+ client_secret = os.environ.get("GA_CLI_CLIENT_SECRET", "")
167
+ return {
168
+ "installed": {
169
+ "client_id": client_id,
170
+ "client_secret": client_secret,
171
+ "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth",
172
+ "token_uri": "https://oauth2.googleapis.com/token",
173
+ "redirect_uris": [f"http://localhost:{OAUTH_CALLBACK_PORT}"],
174
+ }
175
+ }
176
+
177
+
178
+ def login() -> Credentials:
179
+ """Run the full OAuth login flow.
180
+
181
+ Opens a browser, starts a local server, waits for the callback,
182
+ exchanges the code for tokens, and saves credentials to disk.
183
+
184
+ Raises:
185
+ typer.Exit: If client credentials are not configured.
186
+ OSError: If the callback port is already in use.
187
+ """
188
+ client_secret_path = get_client_secret_path()
189
+
190
+ if client_secret_path.exists():
191
+ flow = InstalledAppFlow.from_client_secrets_file(
192
+ str(client_secret_path),
193
+ scopes=OAUTH_SCOPES,
194
+ )
195
+ elif os.environ.get("GA_CLI_CLIENT_ID") and os.environ.get("GA_CLI_CLIENT_SECRET"):
196
+ flow = InstalledAppFlow.from_client_config(
197
+ _get_client_config(),
198
+ scopes=OAUTH_SCOPES,
199
+ )
200
+ else:
201
+ from ..utils.output import err_console
202
+
203
+ err_console.print(Panel(
204
+ "[bold]OAuth client credentials not found.[/bold]\n\n"
205
+ "GA CLI requires your own GCP OAuth credentials. "
206
+ "Choose one of the following:\n\n"
207
+ "[cyan]Option 1:[/cyan] Place a client_secret.json file in:\n"
208
+ f" [green]{client_secret_path}[/green]\n\n"
209
+ "[cyan]Option 2:[/cyan] Set environment variables:\n"
210
+ " [green]GA_CLI_CLIENT_ID[/green] and "
211
+ "[green]GA_CLI_CLIENT_SECRET[/green]\n\n"
212
+ "For step-by-step setup instructions, run:\n"
213
+ " [bold yellow]ga auth setup[/bold yellow]\n\n"
214
+ "Create OAuth credentials in the GCP Console:\n"
215
+ " APIs & Services > Credentials > Create OAuth client ID (Desktop app)",
216
+ title="[red]Missing Credentials[/red]",
217
+ border_style="red",
218
+ expand=False,
219
+ ))
220
+ raise typer.Exit(1)
221
+
222
+ # Custom local server flow to serve a styled HTML success page
223
+ wsgi_app = _HtmlRedirectWSGIApp(_SUCCESS_HTML)
224
+ wsgiref.simple_server.WSGIServer.allow_reuse_address = False
225
+ local_server = wsgiref.simple_server.make_server(
226
+ "localhost",
227
+ OAUTH_CALLBACK_PORT,
228
+ wsgi_app,
229
+ handler_class=_SilentRequestHandler,
230
+ )
231
+
232
+ try:
233
+ flow.redirect_uri = f"http://localhost:{local_server.server_port}/"
234
+ auth_url, _ = flow.authorization_url(
235
+ prompt="consent",
236
+ access_type="offline",
237
+ )
238
+
239
+ webbrowser.open(auth_url, new=1, autoraise=True)
240
+ print(f"Please visit this URL to authorize this application: {auth_url}")
241
+
242
+ local_server.handle_request()
243
+
244
+ authorization_response = wsgi_app.last_request_uri.replace(
245
+ "http", "https"
246
+ )
247
+ flow.fetch_token(authorization_response=authorization_response)
248
+ finally:
249
+ local_server.server_close()
250
+
251
+ credentials = flow.credentials
252
+ save_credentials(credentials)
253
+ return credentials
254
+
255
+
256
+ def logout() -> None:
257
+ """Revoke tokens and delete local credentials.
258
+
259
+ Attempts to revoke the token with Google (best-effort),
260
+ then deletes the local credentials file.
261
+ """
262
+ creds = load_credentials()
263
+
264
+ if creds and creds.token:
265
+ try:
266
+ requests.post(
267
+ "https://oauth2.googleapis.com/revoke",
268
+ params={"token": creds.token},
269
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
270
+ timeout=10,
271
+ )
272
+ except requests.RequestException:
273
+ logger.debug("Token revocation failed (token may already be revoked)")
274
+
275
+ delete_credentials()
276
+
277
+
278
+ def get_auth_status() -> dict:
279
+ """Get current OAuth authentication status.
280
+
281
+ Returns a dict with authentication state, suitable for display.
282
+ """
283
+ creds = load_credentials()
284
+
285
+ if creds is None:
286
+ return {"authenticated": False}
287
+
288
+ result: dict = {
289
+ "authenticated": True,
290
+ "token_valid": creds.valid,
291
+ "expired": creds.expired,
292
+ "has_refresh_token": creds.refresh_token is not None,
293
+ }
294
+
295
+ if creds.expiry:
296
+ result["expires_at"] = creds.expiry.isoformat()
297
+
298
+ # Try to fetch user info if we have a valid or refreshable token
299
+ if creds.valid or (creds.expired and creds.refresh_token):
300
+ valid_creds = get_valid_credentials()
301
+ if valid_creds and valid_creds.token:
302
+ user_info = _fetch_user_info(valid_creds.token)
303
+ if user_info:
304
+ result["email"] = user_info.get("email")
305
+ result["name"] = user_info.get("name")
306
+
307
+ return result
308
+
309
+
310
+ def _fetch_user_info(access_token: str) -> Optional[dict]:
311
+ """Fetch user info from Google (email, name)."""
312
+ try:
313
+ resp = requests.get(
314
+ "https://www.googleapis.com/oauth2/v2/userinfo",
315
+ headers={"Authorization": f"Bearer {access_token}"},
316
+ timeout=10,
317
+ )
318
+ if resp.ok:
319
+ return resp.json()
320
+ except requests.RequestException:
321
+ logger.debug("Failed to fetch user info")
322
+ return None