google-analytics-cli 0.1.0__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.0.dist-info/METADATA +321 -0
- google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
- google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
- google_analytics_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Upgrade command: check for updates and self-update ga-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import __version__
|
|
16
|
+
from ..config.constants import get_update_check_path
|
|
17
|
+
from ..utils import error, info, success, warn
|
|
18
|
+
|
|
19
|
+
upgrade_app = typer.Typer(
|
|
20
|
+
name="upgrade", help="Check for and install updates", invoke_without_command=True
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PYPI_URL = "https://pypi.org/pypi/ga-cli/json"
|
|
24
|
+
PYPI_TIMEOUT = 5
|
|
25
|
+
UPDATE_CHECK_INTERVAL = 86400 # 24 hours in seconds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_pypi_version() -> Optional[str]:
|
|
29
|
+
"""Fetch the latest ga-cli version from PyPI.
|
|
30
|
+
|
|
31
|
+
Returns the version string, or None on any error.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
|
|
35
|
+
with urllib.request.urlopen(req, timeout=PYPI_TIMEOUT) as resp:
|
|
36
|
+
data = json.loads(resp.read().decode())
|
|
37
|
+
return data["info"]["version"]
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
43
|
+
"""Return True if latest is strictly newer than current."""
|
|
44
|
+
from packaging.version import parse
|
|
45
|
+
|
|
46
|
+
return parse(latest) > parse(current)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _detect_installer() -> list[str]:
|
|
50
|
+
"""Detect the best install command to upgrade ga-cli.
|
|
51
|
+
|
|
52
|
+
Checks for pipx first, then falls back to pip via the current interpreter.
|
|
53
|
+
"""
|
|
54
|
+
if shutil.which("pipx"):
|
|
55
|
+
return ["pipx", "upgrade", "ga-cli"]
|
|
56
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", "ga-cli"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@upgrade_app.callback(invoke_without_command=True)
|
|
60
|
+
def upgrade_cmd(
|
|
61
|
+
check: bool = typer.Option(False, "--check", help="Check for updates without installing"),
|
|
62
|
+
force: bool = typer.Option(False, "--force", help="Force reinstall current version"),
|
|
63
|
+
):
|
|
64
|
+
"""Check for and install updates."""
|
|
65
|
+
current = __version__
|
|
66
|
+
|
|
67
|
+
if check:
|
|
68
|
+
latest = _check_pypi_version()
|
|
69
|
+
if latest is None:
|
|
70
|
+
error("Could not check for updates. Please check your network connection.")
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
if _is_newer(latest, current):
|
|
73
|
+
info(f"Update available: {current} → {latest}. Run 'ga upgrade' to install.")
|
|
74
|
+
else:
|
|
75
|
+
success(f"ga-cli {current} is the latest version.")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if force:
|
|
79
|
+
info(f"Force-reinstalling ga-cli {current}...")
|
|
80
|
+
cmd = _detect_installer()
|
|
81
|
+
# Append --force-reinstall for pip-based installs
|
|
82
|
+
if cmd[0] != "pipx":
|
|
83
|
+
cmd.append("--force-reinstall")
|
|
84
|
+
else:
|
|
85
|
+
cmd = ["pipx", "install", "--force", "ga-cli"]
|
|
86
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
success(f"Reinstalled ga-cli {current}.")
|
|
89
|
+
else:
|
|
90
|
+
error(f"Upgrade failed:\n{result.stderr.strip()}")
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# Default: check and upgrade
|
|
95
|
+
latest = _check_pypi_version()
|
|
96
|
+
if latest is None:
|
|
97
|
+
error("Could not check for updates. Please check your network connection.")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
if not _is_newer(latest, current):
|
|
101
|
+
success(f"ga-cli {current} is already up to date.")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
info(f"Upgrading ga-cli {current} → {latest}...")
|
|
105
|
+
cmd = _detect_installer()
|
|
106
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
107
|
+
if result.returncode == 0:
|
|
108
|
+
success(f"Successfully upgraded to ga-cli {latest}.")
|
|
109
|
+
else:
|
|
110
|
+
error(f"Upgrade failed:\n{result.stderr.strip()}")
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def maybe_check_for_updates() -> None:
|
|
115
|
+
"""Run a non-blocking daily update check.
|
|
116
|
+
|
|
117
|
+
Called from main.py after command dispatch. Prints a dim notice
|
|
118
|
+
to stderr if a newer version is available. Never raises.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
check_path = get_update_check_path()
|
|
122
|
+
|
|
123
|
+
# Read last check timestamp
|
|
124
|
+
last_check = 0.0
|
|
125
|
+
if check_path.exists():
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(check_path.read_text())
|
|
128
|
+
last_check = data.get("last_check", 0.0)
|
|
129
|
+
except (json.JSONDecodeError, OSError):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
now = time.time()
|
|
133
|
+
if now - last_check < UPDATE_CHECK_INTERVAL:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
latest = _check_pypi_version()
|
|
137
|
+
|
|
138
|
+
# Write new timestamp regardless of result
|
|
139
|
+
check_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
check_path.write_text(json.dumps({"last_check": now}))
|
|
141
|
+
|
|
142
|
+
if latest and _is_newer(latest, __version__):
|
|
143
|
+
warn(
|
|
144
|
+
f"A new version of ga-cli is available (v{latest}). "
|
|
145
|
+
"Run 'ga upgrade' to update."
|
|
146
|
+
)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Application constants and configuration paths."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from platformdirs import user_config_dir
|
|
7
|
+
|
|
8
|
+
# App identity
|
|
9
|
+
APP_NAME = "ga-cli"
|
|
10
|
+
|
|
11
|
+
# OAuth 2.0 scopes
|
|
12
|
+
OAUTH_SCOPES = [
|
|
13
|
+
"openid",
|
|
14
|
+
"https://www.googleapis.com/auth/analytics.readonly",
|
|
15
|
+
"https://www.googleapis.com/auth/analytics.edit",
|
|
16
|
+
"https://www.googleapis.com/auth/analytics.manage.users",
|
|
17
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
18
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# OAuth callback server
|
|
22
|
+
OAUTH_CALLBACK_PORT = 8085
|
|
23
|
+
|
|
24
|
+
# Configuration directory
|
|
25
|
+
# Priority: GA_CLI_CONFIG_DIR env var > platformdirs (XDG-compliant)
|
|
26
|
+
def get_config_dir() -> Path:
|
|
27
|
+
"""Get the configuration directory path."""
|
|
28
|
+
env_dir = os.environ.get("GA_CLI_CONFIG_DIR")
|
|
29
|
+
if env_dir:
|
|
30
|
+
return Path(env_dir)
|
|
31
|
+
return Path(user_config_dir(APP_NAME)) # ~/.config/ga-cli/ on Linux/macOS
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_credentials_path() -> Path:
|
|
35
|
+
"""Path to stored OAuth credentials."""
|
|
36
|
+
return get_config_dir() / "credentials.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_config_path() -> Path:
|
|
40
|
+
"""Path to user configuration file."""
|
|
41
|
+
return get_config_dir() / "config.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_auth_method_path() -> Path:
|
|
45
|
+
"""Path to auth method tracking file."""
|
|
46
|
+
return get_config_dir() / "auth-method.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_client_secret_path() -> Path:
|
|
50
|
+
"""Path to OAuth client secret file (alternative to env vars)."""
|
|
51
|
+
return get_config_dir() / "client_secret.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_update_check_path() -> Path:
|
|
55
|
+
"""Path to update-check timestamp file."""
|
|
56
|
+
return get_config_dir() / "update-check.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Pagination defaults
|
|
60
|
+
DEFAULT_PAGE_SIZE = 50
|
|
61
|
+
MAX_PAGE_SIZE = 200
|
ga_cli/config/store.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""User configuration persistence.
|
|
2
|
+
|
|
3
|
+
Implements the same pattern as GTM CLI:
|
|
4
|
+
- JSON file at ~/.config/ga-cli/config.json
|
|
5
|
+
- In-memory cache for performance
|
|
6
|
+
- CLI flag > config file > None resolution via get_effective_value()
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from typing import Literal, Optional
|
|
14
|
+
|
|
15
|
+
from .constants import get_config_dir, get_config_path
|
|
16
|
+
|
|
17
|
+
OutputFormat = Literal["json", "table", "compact"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class UserConfig:
|
|
22
|
+
"""User configuration. GA-specific fields."""
|
|
23
|
+
|
|
24
|
+
default_property_id: Optional[str] = None
|
|
25
|
+
default_account_id: Optional[str] = None
|
|
26
|
+
output_format: OutputFormat = "table"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# In-memory cache (same pattern as GTM CLI)
|
|
30
|
+
_cached_config: Optional[UserConfig] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config() -> UserConfig:
|
|
34
|
+
"""Load config from disk, merge with defaults, cache in memory."""
|
|
35
|
+
global _cached_config
|
|
36
|
+
if _cached_config is not None:
|
|
37
|
+
return _cached_config
|
|
38
|
+
|
|
39
|
+
config_path = get_config_path()
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(config_path.read_text())
|
|
42
|
+
_cached_config = UserConfig(
|
|
43
|
+
default_property_id=data.get("default_property_id"),
|
|
44
|
+
default_account_id=data.get("default_account_id"),
|
|
45
|
+
output_format=data.get("output_format", "table"),
|
|
46
|
+
)
|
|
47
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
48
|
+
_cached_config = UserConfig()
|
|
49
|
+
|
|
50
|
+
return _cached_config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_config(config: UserConfig) -> None:
|
|
54
|
+
"""Write config to disk and update cache."""
|
|
55
|
+
global _cached_config
|
|
56
|
+
config_dir = get_config_dir()
|
|
57
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# Only write non-None values
|
|
60
|
+
data = {k: v for k, v in asdict(config).items() if v is not None}
|
|
61
|
+
get_config_path().write_text(json.dumps(data, indent=2))
|
|
62
|
+
_cached_config = config
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def update_config(**updates: str) -> UserConfig:
|
|
66
|
+
"""Merge updates into current config and save."""
|
|
67
|
+
config = load_config()
|
|
68
|
+
for key, value in updates.items():
|
|
69
|
+
if hasattr(config, key):
|
|
70
|
+
setattr(config, key, value)
|
|
71
|
+
save_config(config)
|
|
72
|
+
return config
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_config_value(key: str) -> Optional[str]:
|
|
76
|
+
"""Get a single config value by key name."""
|
|
77
|
+
config = load_config()
|
|
78
|
+
return getattr(config, key, None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_config_value(key: str, value: str) -> None:
|
|
82
|
+
"""Set a single config value."""
|
|
83
|
+
update_config(**{key: value})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def unset_config_value(key: str) -> None:
|
|
87
|
+
"""Remove a config value (set to None)."""
|
|
88
|
+
config = load_config()
|
|
89
|
+
if hasattr(config, key):
|
|
90
|
+
setattr(config, key, None)
|
|
91
|
+
save_config(config)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clear_config() -> None:
|
|
95
|
+
"""Reset config to defaults."""
|
|
96
|
+
save_config(UserConfig())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_effective_value(cli_value: Optional[str], config_key: str) -> Optional[str]:
|
|
100
|
+
"""Resolve a value: CLI flag > config file > None.
|
|
101
|
+
|
|
102
|
+
This is the critical resolution function used by every command.
|
|
103
|
+
Equivalent to GTM CLI's getEffectiveValue().
|
|
104
|
+
"""
|
|
105
|
+
if cli_value:
|
|
106
|
+
return cli_value
|
|
107
|
+
return get_config_value(config_key)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Valid config keys (for validation in set/unset commands)
|
|
111
|
+
VALID_CONFIG_KEYS = {
|
|
112
|
+
"default_property_id": "Default GA4 Property ID",
|
|
113
|
+
"default_account_id": "Default Account ID",
|
|
114
|
+
"output_format": "Output format (json, table, compact)",
|
|
115
|
+
}
|
ga_cli/main.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""GA CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .commands.access_bindings import access_bindings_app
|
|
6
|
+
from .commands.access_reports import access_reports_app
|
|
7
|
+
from .commands.account_summaries import account_summaries_app
|
|
8
|
+
from .commands.accounts import accounts_app
|
|
9
|
+
from .commands.agent_cmd import agent_app
|
|
10
|
+
from .commands.annotations import annotations_app
|
|
11
|
+
from .commands.audiences import audiences_app
|
|
12
|
+
from .commands.auth_cmd import auth_app
|
|
13
|
+
from .commands.bigquery_links import bigquery_links_app
|
|
14
|
+
from .commands.calculated_metrics import calculated_metrics_app
|
|
15
|
+
from .commands.channel_groups import channel_groups_app
|
|
16
|
+
from .commands.completions_cmd import completions_app
|
|
17
|
+
from .commands.config_cmd import config_app
|
|
18
|
+
from .commands.custom_dimensions import custom_dimensions_app
|
|
19
|
+
from .commands.custom_metrics import custom_metrics_app
|
|
20
|
+
from .commands.data_retention import data_retention_app
|
|
21
|
+
from .commands.data_streams import data_streams_app
|
|
22
|
+
from .commands.event_create_rules import event_create_rules_app
|
|
23
|
+
from .commands.event_edit_rules import event_edit_rules_app
|
|
24
|
+
from .commands.firebase_links import firebase_links_app
|
|
25
|
+
from .commands.google_ads_links import google_ads_links_app
|
|
26
|
+
from .commands.key_events import key_events_app
|
|
27
|
+
from .commands.mp_secrets import mp_secrets_app
|
|
28
|
+
from .commands.properties import properties_app
|
|
29
|
+
from .commands.property_settings import property_settings_app
|
|
30
|
+
from .commands.reports import reports_app
|
|
31
|
+
from .commands.upgrade_cmd import upgrade_app
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="ga",
|
|
35
|
+
help="Command-line interface for Google Analytics 4",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Register command groups
|
|
40
|
+
app.add_typer(auth_app, name="auth")
|
|
41
|
+
app.add_typer(config_app, name="config")
|
|
42
|
+
app.add_typer(accounts_app, name="accounts")
|
|
43
|
+
app.add_typer(account_summaries_app, name="account-summaries")
|
|
44
|
+
app.add_typer(properties_app, name="properties")
|
|
45
|
+
app.add_typer(custom_dimensions_app, name="custom-dimensions")
|
|
46
|
+
app.add_typer(custom_metrics_app, name="custom-metrics")
|
|
47
|
+
app.add_typer(data_retention_app, name="data-retention")
|
|
48
|
+
app.add_typer(data_streams_app, name="data-streams")
|
|
49
|
+
app.add_typer(access_bindings_app, name="access-bindings")
|
|
50
|
+
app.add_typer(access_reports_app, name="access-reports")
|
|
51
|
+
app.add_typer(annotations_app, name="annotations")
|
|
52
|
+
app.add_typer(audiences_app, name="audiences")
|
|
53
|
+
app.add_typer(bigquery_links_app, name="bigquery-links")
|
|
54
|
+
app.add_typer(calculated_metrics_app, name="calculated-metrics")
|
|
55
|
+
app.add_typer(channel_groups_app, name="channel-groups")
|
|
56
|
+
app.add_typer(event_create_rules_app, name="event-create-rules")
|
|
57
|
+
app.add_typer(event_edit_rules_app, name="event-edit-rules")
|
|
58
|
+
app.add_typer(firebase_links_app, name="firebase-links")
|
|
59
|
+
app.add_typer(google_ads_links_app, name="google-ads-links")
|
|
60
|
+
app.add_typer(key_events_app, name="key-events")
|
|
61
|
+
app.add_typer(mp_secrets_app, name="mp-secrets")
|
|
62
|
+
app.add_typer(property_settings_app, name="property-settings")
|
|
63
|
+
app.add_typer(reports_app, name="reports")
|
|
64
|
+
app.add_typer(agent_app, name="agent")
|
|
65
|
+
app.add_typer(upgrade_app, name="upgrade")
|
|
66
|
+
app.add_typer(completions_app, name="completions")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback(invoke_without_command=True)
|
|
70
|
+
def main(
|
|
71
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
72
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"),
|
|
73
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
74
|
+
describe: bool = typer.Option(False, "--describe", help="Show CLI schema as JSON"),
|
|
75
|
+
):
|
|
76
|
+
"""GA CLI — Command-line interface for Google Analytics 4."""
|
|
77
|
+
from .config.store import get_effective_value
|
|
78
|
+
from .utils.output import is_tty, set_no_color, set_output_format, set_quiet
|
|
79
|
+
|
|
80
|
+
if quiet:
|
|
81
|
+
set_quiet(True)
|
|
82
|
+
if no_color:
|
|
83
|
+
set_no_color(True)
|
|
84
|
+
|
|
85
|
+
# Resolve output format early so errors are structured when appropriate
|
|
86
|
+
effective_fmt = get_effective_value(None, "output_format") or ("table" if is_tty() else "json")
|
|
87
|
+
set_output_format(effective_fmt)
|
|
88
|
+
|
|
89
|
+
if version:
|
|
90
|
+
from . import __version__
|
|
91
|
+
|
|
92
|
+
print(f"ga-cli {__version__}")
|
|
93
|
+
raise typer.Exit()
|
|
94
|
+
|
|
95
|
+
if describe:
|
|
96
|
+
from .utils.describe import handle_describe_all
|
|
97
|
+
|
|
98
|
+
handle_describe_all(app)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run():
|
|
102
|
+
"""Entry point for the CLI (called by pyproject.toml [project.scripts])."""
|
|
103
|
+
from .commands.upgrade_cmd import maybe_check_for_updates
|
|
104
|
+
|
|
105
|
+
app()
|
|
106
|
+
maybe_check_for_updates()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
run()
|
ga_cli/utils/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Utility re-exports."""
|
|
2
|
+
|
|
3
|
+
from .describe import handle_describe_all as handle_describe_all
|
|
4
|
+
from .dry_run import handle_dry_run as handle_dry_run
|
|
5
|
+
from .errors import classify_error as classify_error
|
|
6
|
+
from .errors import format_api_error as format_api_error
|
|
7
|
+
from .errors import handle_error as handle_error
|
|
8
|
+
from .errors import require_options as require_options
|
|
9
|
+
from .output import OutputFormat as OutputFormat
|
|
10
|
+
from .output import console as console
|
|
11
|
+
from .output import error as error
|
|
12
|
+
from .output import get_output_format as get_output_format
|
|
13
|
+
from .output import info as info
|
|
14
|
+
from .output import output as output
|
|
15
|
+
from .output import resolve_output_format as resolve_output_format
|
|
16
|
+
from .output import success as success
|
|
17
|
+
from .output import warn as warn
|
|
18
|
+
from .pagination import PaginatedResult as PaginatedResult
|
|
19
|
+
from .pagination import paginate as paginate
|
|
20
|
+
from .pagination import paginate_all as paginate_all
|
ga_cli/utils/describe.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Schema introspection for --describe flag.
|
|
2
|
+
|
|
3
|
+
Auto-extracts command schemas from Typer/Click internals.
|
|
4
|
+
No manual registration needed — parameters, types, flags, and
|
|
5
|
+
metadata (mutative, dry-run, json-input) are all derived from
|
|
6
|
+
the live command definitions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _click_type_to_json(param_type: click.ParamType) -> str:
|
|
17
|
+
"""Map a Click parameter type to a JSON Schema type string."""
|
|
18
|
+
if isinstance(param_type, click.types.IntParamType):
|
|
19
|
+
return "integer"
|
|
20
|
+
if isinstance(param_type, click.types.FloatParamType):
|
|
21
|
+
return "number"
|
|
22
|
+
if isinstance(param_type, click.types.BoolParamType):
|
|
23
|
+
return "boolean"
|
|
24
|
+
return "string"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Parameters to exclude from schema output (meta/infrastructure flags).
|
|
28
|
+
_EXCLUDED_PARAMS = frozenset({
|
|
29
|
+
"help", "version", "quiet", "no_color", "describe",
|
|
30
|
+
"dry_run", "json_input",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _param_to_schema(param: click.Parameter) -> tuple[str, dict] | None:
|
|
35
|
+
"""Convert a Click parameter to a (name, JSON Schema property) pair.
|
|
36
|
+
|
|
37
|
+
Returns None for parameters that should be excluded from output.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(param, click.Option):
|
|
40
|
+
return None
|
|
41
|
+
if param.name in _EXCLUDED_PARAMS:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
prop: dict = {
|
|
45
|
+
"type": _click_type_to_json(param.type),
|
|
46
|
+
"description": param.help or "",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
long_flags = [o for o in param.opts if o.startswith("--")]
|
|
50
|
+
short_flags = [o for o in param.opts if o.startswith("-") and not o.startswith("--")]
|
|
51
|
+
if long_flags:
|
|
52
|
+
prop["flag"] = long_flags[0]
|
|
53
|
+
if short_flags:
|
|
54
|
+
prop["aliases"] = short_flags
|
|
55
|
+
|
|
56
|
+
if isinstance(param.type, click.Choice):
|
|
57
|
+
prop["enum"] = list(param.type.choices)
|
|
58
|
+
|
|
59
|
+
if param.default is not None and not param.required:
|
|
60
|
+
prop["default"] = param.default
|
|
61
|
+
|
|
62
|
+
return param.name, prop
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _introspect_command(cmd: click.Command, prefix: str) -> dict:
|
|
66
|
+
"""Build a JSON-Schema-like descriptor for a single Click command."""
|
|
67
|
+
full_name = f"{prefix} {cmd.name}" if prefix else cmd.name
|
|
68
|
+
|
|
69
|
+
properties: dict = {}
|
|
70
|
+
required: list[str] = []
|
|
71
|
+
has_dry_run = False
|
|
72
|
+
has_json_input = False
|
|
73
|
+
|
|
74
|
+
for param in cmd.params:
|
|
75
|
+
if param.name == "dry_run":
|
|
76
|
+
has_dry_run = True
|
|
77
|
+
if param.name == "json_input":
|
|
78
|
+
has_json_input = True
|
|
79
|
+
|
|
80
|
+
result = _param_to_schema(param)
|
|
81
|
+
if result is None:
|
|
82
|
+
continue
|
|
83
|
+
name, prop = result
|
|
84
|
+
properties[name] = prop
|
|
85
|
+
if param.required:
|
|
86
|
+
required.append(name)
|
|
87
|
+
|
|
88
|
+
schema: dict = {
|
|
89
|
+
"command": full_name,
|
|
90
|
+
"description": cmd.help or "",
|
|
91
|
+
"parameters": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": properties,
|
|
94
|
+
},
|
|
95
|
+
"mutative": has_dry_run,
|
|
96
|
+
"supports_dry_run": has_dry_run,
|
|
97
|
+
"supports_json_input": has_json_input,
|
|
98
|
+
}
|
|
99
|
+
if required:
|
|
100
|
+
schema["parameters"]["required"] = required
|
|
101
|
+
|
|
102
|
+
return schema
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _introspect_group(group: click.Group, prefix: str) -> list[dict]:
|
|
106
|
+
"""Recursively introspect a Click group and all its sub-commands."""
|
|
107
|
+
commands: list[dict] = []
|
|
108
|
+
for name in sorted(group.list_commands(None)): # type: ignore[arg-type]
|
|
109
|
+
cmd = group.get_command(None, name) # type: ignore[arg-type]
|
|
110
|
+
if cmd is None:
|
|
111
|
+
continue
|
|
112
|
+
full_prefix = f"{prefix} {name}" if prefix else name
|
|
113
|
+
if isinstance(cmd, click.Group):
|
|
114
|
+
commands.extend(_introspect_group(cmd, full_prefix))
|
|
115
|
+
else:
|
|
116
|
+
commands.append(_introspect_command(cmd, prefix))
|
|
117
|
+
return commands
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def handle_describe_all(typer_app: typer.Typer) -> None:
|
|
121
|
+
"""Output schemas for all CLI commands as JSON and exit."""
|
|
122
|
+
click_group = typer.main.get_group(typer_app)
|
|
123
|
+
schemas = _introspect_group(click_group, "ga")
|
|
124
|
+
result = {
|
|
125
|
+
"cli": "ga-cli",
|
|
126
|
+
"commands": {s["command"]: s for s in schemas},
|
|
127
|
+
}
|
|
128
|
+
print(json.dumps(result, indent=2), file=sys.stdout)
|
|
129
|
+
raise typer.Exit(0)
|
ga_cli/utils/dry_run.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Dry-run support for mutative commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_dry_run(
|
|
10
|
+
action: str,
|
|
11
|
+
method: str,
|
|
12
|
+
resource_path: str,
|
|
13
|
+
body: dict | None,
|
|
14
|
+
update_mask: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Output what would be sent to the API and exit.
|
|
17
|
+
|
|
18
|
+
Always outputs JSON regardless of the current output format,
|
|
19
|
+
since dry-run consumers are primarily agents.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
action: "create", "update", "delete", "archive", or "acknowledge"
|
|
23
|
+
method: HTTP method equivalent ("POST", "PATCH", "DELETE")
|
|
24
|
+
resource_path: Full API resource path (e.g., "properties/123456")
|
|
25
|
+
body: Request body dict, or None for deletes
|
|
26
|
+
update_mask: For PATCH requests, the updateMask value
|
|
27
|
+
"""
|
|
28
|
+
payload: dict = {
|
|
29
|
+
"dry_run": True,
|
|
30
|
+
"action": action,
|
|
31
|
+
"method": method,
|
|
32
|
+
"resource": resource_path,
|
|
33
|
+
"idempotent": action == "delete",
|
|
34
|
+
}
|
|
35
|
+
if body is not None:
|
|
36
|
+
payload["body"] = body
|
|
37
|
+
if update_mask is not None:
|
|
38
|
+
payload["update_mask"] = update_mask
|
|
39
|
+
print(json.dumps(payload, indent=2), file=sys.stdout)
|
|
40
|
+
raise typer.Exit(0)
|