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.
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.0.dist-info/METADATA +321 -0
  47. google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
  48. google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
  49. 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()
@@ -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
@@ -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)
@@ -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)