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.
- ga_cli/__init__.py +8 -0
- ga_cli/api/__init__.py +0 -0
- ga_cli/api/client.py +142 -0
- ga_cli/auth/__init__.py +35 -0
- ga_cli/auth/credentials.py +126 -0
- ga_cli/auth/oauth.py +322 -0
- ga_cli/auth/service_account.py +155 -0
- ga_cli/commands/__init__.py +0 -0
- ga_cli/commands/access_bindings.py +254 -0
- ga_cli/commands/access_reports.py +201 -0
- ga_cli/commands/account_summaries.py +68 -0
- ga_cli/commands/accounts.py +297 -0
- ga_cli/commands/agent_cmd.py +776 -0
- ga_cli/commands/annotations.py +264 -0
- ga_cli/commands/audiences.py +223 -0
- ga_cli/commands/auth_cmd.py +205 -0
- ga_cli/commands/bigquery_links.py +309 -0
- ga_cli/commands/calculated_metrics.py +312 -0
- ga_cli/commands/channel_groups.py +223 -0
- ga_cli/commands/completions_cmd.py +55 -0
- ga_cli/commands/config_cmd.py +113 -0
- ga_cli/commands/custom_dimensions.py +272 -0
- ga_cli/commands/custom_metrics.py +305 -0
- ga_cli/commands/data_retention.py +153 -0
- ga_cli/commands/data_streams.py +277 -0
- ga_cli/commands/event_create_rules.py +250 -0
- ga_cli/commands/event_edit_rules.py +292 -0
- ga_cli/commands/firebase_links.py +142 -0
- ga_cli/commands/google_ads_links.py +225 -0
- ga_cli/commands/key_events.py +269 -0
- ga_cli/commands/mp_secrets.py +265 -0
- ga_cli/commands/properties.py +330 -0
- ga_cli/commands/property_settings.py +287 -0
- ga_cli/commands/reports.py +726 -0
- ga_cli/commands/upgrade_cmd.py +148 -0
- ga_cli/config/__init__.py +0 -0
- ga_cli/config/constants.py +61 -0
- ga_cli/config/store.py +115 -0
- ga_cli/main.py +110 -0
- ga_cli/utils/__init__.py +20 -0
- ga_cli/utils/describe.py +129 -0
- ga_cli/utils/dry_run.py +40 -0
- ga_cli/utils/errors.py +150 -0
- ga_cli/utils/output.py +209 -0
- ga_cli/utils/pagination.py +93 -0
- google_analytics_cli-0.1.0rc1.dist-info/METADATA +269 -0
- google_analytics_cli-0.1.0rc1.dist-info/RECORD +49 -0
- google_analytics_cli-0.1.0rc1.dist-info/WHEEL +4 -0
- google_analytics_cli-0.1.0rc1.dist-info/entry_points.txt +2 -0
ga_cli/__init__.py
ADDED
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
|
ga_cli/auth/__init__.py
ADDED
|
@@ -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
|