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
ga_cli/utils/errors.py ADDED
@@ -0,0 +1,150 @@
1
+ """Error handling utilities.
2
+
3
+ Provides consistent error formatting for Google API errors.
4
+ When the output format is JSON, errors are emitted as structured JSON
5
+ to stderr with distinct exit codes for different failure categories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import NoReturn
13
+
14
+ from .output import error as print_error
15
+
16
+
17
+ def format_api_error(err: Exception) -> str:
18
+ """Extract a human-readable message from a Google API error.
19
+
20
+ Handles googleapiclient.errors.HttpError and generic exceptions.
21
+ """
22
+ # google-api-python-client raises HttpError
23
+ from googleapiclient.errors import HttpError
24
+
25
+ if isinstance(err, HttpError):
26
+ try:
27
+ detail = json.loads(err.content.decode())
28
+ message = detail.get("error", {}).get("message", str(err))
29
+ return message
30
+ except (json.JSONDecodeError, AttributeError):
31
+ return str(err)
32
+
33
+ return str(err)
34
+
35
+
36
+ def classify_error(err: Exception) -> tuple[int, str]:
37
+ """Classify an exception into (exit_code, category).
38
+
39
+ Exit codes:
40
+ 1 — client_error: bad input, missing flags, validation failure
41
+ 2 — auth_error: not authenticated, token expired, permission denied (401/403)
42
+ 3 — api_error: Google returned 4xx (not auth) or 5xx
43
+ 4 — network_error: connection timeout, DNS failure, unreachable
44
+
45
+ Returns:
46
+ (exit_code, category)
47
+ """
48
+ from googleapiclient.errors import HttpError
49
+
50
+ if isinstance(err, HttpError):
51
+ status = err.resp.status
52
+ if status in (401, 403):
53
+ return 2, "auth_error"
54
+ return 3, "api_error"
55
+
56
+ # Auth-specific exceptions
57
+ try:
58
+ from google.auth.exceptions import DefaultCredentialsError, RefreshError
59
+
60
+ if isinstance(err, (RefreshError, DefaultCredentialsError)):
61
+ return 2, "auth_error"
62
+ except ImportError:
63
+ pass
64
+
65
+ # Network errors
66
+ try:
67
+ from requests.exceptions import ConnectionError as RequestsConnectionError
68
+ from requests.exceptions import Timeout
69
+
70
+ if isinstance(err, (RequestsConnectionError, Timeout)):
71
+ return 4, "network_error"
72
+ except ImportError:
73
+ pass
74
+
75
+ try:
76
+ from urllib3.exceptions import NewConnectionError
77
+
78
+ if isinstance(err, NewConnectionError):
79
+ return 4, "network_error"
80
+ except ImportError:
81
+ pass
82
+
83
+ if isinstance(err, OSError) and _is_network_os_error(err):
84
+ return 4, "network_error"
85
+
86
+ # RuntimeError from our own auth code ("Not authenticated...")
87
+ if isinstance(err, RuntimeError) and "auth" in str(err).lower():
88
+ return 2, "auth_error"
89
+
90
+ return 3, "api_error"
91
+
92
+
93
+ def _is_network_os_error(err: OSError) -> bool:
94
+ """Check if an OSError is network-related (vs file I/O, permissions, etc.)."""
95
+ import errno
96
+
97
+ network_errnos = {
98
+ errno.ECONNREFUSED,
99
+ errno.ECONNRESET,
100
+ errno.ECONNABORTED,
101
+ errno.ETIMEDOUT,
102
+ errno.EHOSTUNREACH,
103
+ errno.ENETUNREACH,
104
+ }
105
+ return err.errno in network_errnos
106
+
107
+
108
+ def handle_error(err: Exception) -> NoReturn:
109
+ """Print error and exit with a classified exit code.
110
+
111
+ When output format is JSON (explicit or non-TTY), emit structured
112
+ JSON to stderr. Otherwise, emit human-readable Rich text.
113
+ """
114
+ from .output import get_current_output_format
115
+
116
+ exit_code, category = classify_error(err)
117
+ message = format_api_error(err)
118
+ fmt = get_current_output_format()
119
+
120
+ if fmt == "json":
121
+ payload: dict = {
122
+ "error": True,
123
+ "exit_code": exit_code,
124
+ "category": category,
125
+ "message": message,
126
+ }
127
+ from googleapiclient.errors import HttpError
128
+
129
+ if isinstance(err, HttpError):
130
+ payload["status_code"] = err.resp.status
131
+
132
+ # Write raw JSON to stderr — avoid Rich Console which may wrap lines
133
+ print(json.dumps(payload, default=str), file=sys.stderr)
134
+ else:
135
+ print_error(message)
136
+
137
+ sys.exit(exit_code)
138
+
139
+
140
+ def require_options(options: dict, required: list[str]) -> None:
141
+ """Validate that required options are present.
142
+
143
+ Raises typer.BadParameter if any are missing.
144
+ """
145
+ import typer
146
+
147
+ missing = [k for k in required if not options.get(k)]
148
+ if missing:
149
+ formatted = [f"--{k.replace('_', '-')}" for k in missing]
150
+ raise typer.BadParameter(f"Missing required options: {', '.join(formatted)}")
ga_cli/utils/output.py ADDED
@@ -0,0 +1,209 @@
1
+ """Output formatting utilities.
2
+
3
+ Supports three formats:
4
+ - json: Machine-readable JSON (default when piping)
5
+ - table: Human-readable Rich tables (default for TTY)
6
+ - compact: Minimal ID + name output
7
+
8
+ Equivalent to GTM CLI's utils/output.ts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from typing import Any, Optional
16
+
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+ OutputFormat = str # "json" | "table" | "compact"
24
+
25
+ # Global flags set from main.py callback
26
+ _quiet = False
27
+ _current_output_format: str = "table"
28
+
29
+
30
+ def set_output_format(fmt: str) -> None:
31
+ """Store the resolved output format for use by error handling."""
32
+ global _current_output_format
33
+ _current_output_format = fmt
34
+
35
+
36
+ def get_current_output_format() -> str:
37
+ """Return the current output format. Used by errors.py for structured errors."""
38
+ return _current_output_format
39
+
40
+
41
+ def set_quiet(value: bool) -> None:
42
+ """Enable or disable quiet mode (suppresses info/warn/success)."""
43
+ global _quiet
44
+ _quiet = value
45
+
46
+
47
+ def set_no_color(value: bool) -> None:
48
+ """Enable or disable no-color mode on both consoles."""
49
+ global console, err_console
50
+ if value:
51
+ console = Console(no_color=True, highlight=False)
52
+ err_console = Console(stderr=True, no_color=True, highlight=False)
53
+
54
+
55
+ def is_tty() -> bool:
56
+ """Check if stdout is a terminal."""
57
+ return sys.stdout.isatty()
58
+
59
+
60
+ def get_output_format(requested: Optional[str] = None) -> str:
61
+ """Get effective output format. Defaults to JSON when piping."""
62
+ if requested:
63
+ return requested
64
+ return "table" if is_tty() else "json"
65
+
66
+
67
+ def resolve_output_format(cli_value: Optional[str] = None) -> str:
68
+ """Resolve and track the output format from a subcommand's -o flag.
69
+
70
+ Replaces the common pattern:
71
+ effective_format = get_effective_value(output_format, "output_format") or "table"
72
+
73
+ This resolves CLI flag > config > TTY detection, then updates the
74
+ global format so that handle_error() uses the correct format.
75
+ """
76
+ from ..config.store import get_effective_value
77
+
78
+ fmt = get_effective_value(cli_value, "output_format") or ("table" if is_tty() else "json")
79
+ set_output_format(fmt)
80
+ return fmt
81
+
82
+
83
+ def output(
84
+ data: Any,
85
+ fmt: str = "table",
86
+ columns: Optional[list[str]] = None,
87
+ headers: Optional[list[str]] = None,
88
+ ) -> None:
89
+ """Output data in the specified format.
90
+
91
+ Args:
92
+ data: The data to display (list of dicts, or a single dict).
93
+ fmt: Output format — "json", "table", or "compact".
94
+ columns: Which keys to show in table mode.
95
+ headers: Column headers for table mode.
96
+ """
97
+ effective_fmt = get_output_format(fmt)
98
+
99
+ if effective_fmt == "json":
100
+ print(json.dumps(data, indent=2, default=str))
101
+
102
+ elif effective_fmt == "compact":
103
+ if isinstance(data, list):
104
+ for item in data:
105
+ if isinstance(item, dict):
106
+ name = item.get("name", item.get("displayName", ""))
107
+ display = item.get("displayName", "")
108
+ print(f"{name}\t{display}")
109
+ else:
110
+ print(str(item))
111
+ else:
112
+ print(json.dumps(data, default=str))
113
+
114
+ else: # table
115
+ if isinstance(data, list) and len(data) > 0:
116
+ _output_table(data, columns, headers)
117
+ elif isinstance(data, dict):
118
+ _output_object(data)
119
+ elif isinstance(data, list) and len(data) == 0:
120
+ console.print("No results found.")
121
+ else:
122
+ print(str(data))
123
+
124
+
125
+ def _output_table(
126
+ data: list[dict],
127
+ columns: Optional[list[str]] = None,
128
+ headers: Optional[list[str]] = None,
129
+ ) -> None:
130
+ """Render a list of dicts as a Rich table."""
131
+ if not data:
132
+ console.print("No results found.")
133
+ return
134
+
135
+ cols = columns or _get_default_columns(data[0])
136
+ hdrs = headers or [_format_header(c) for c in cols]
137
+
138
+ table = Table(show_lines=True)
139
+ for h in hdrs:
140
+ table.add_column(h, style="bold")
141
+
142
+ for item in data:
143
+ row = [_format_value(item.get(c)) for c in cols]
144
+ table.add_row(*row)
145
+
146
+ console.print(table)
147
+
148
+
149
+ def _output_object(data: dict) -> None:
150
+ """Render a single dict as key-value pairs."""
151
+ table = Table(show_lines=True)
152
+ table.add_column("Key", style="bold")
153
+ table.add_column("Value")
154
+
155
+ for key, value in data.items():
156
+ if value is not None:
157
+ table.add_row(_format_header(key), _format_value(value))
158
+
159
+ console.print(table)
160
+
161
+
162
+ def _get_default_columns(item: dict) -> list[str]:
163
+ """Pick sensible default columns for GA resources."""
164
+ priority = ["name", "displayName", "type", "createTime", "updateTime"]
165
+ return [c for c in priority if c in item] or list(item.keys())[:5]
166
+
167
+
168
+ def _format_header(key: str) -> str:
169
+ """Convert snake_case or camelCase to Title Case."""
170
+ import re
171
+ # camelCase → spaces
172
+ s = re.sub(r"([A-Z])", r" \1", key)
173
+ # snake_case → spaces
174
+ s = s.replace("_", " ")
175
+ return s.strip().title()
176
+
177
+
178
+ def _format_value(value: Any) -> str:
179
+ """Format a value for table display."""
180
+ if value is None:
181
+ return ""
182
+ if isinstance(value, bool):
183
+ return "[green]Yes[/green]" if value else "[red]No[/red]"
184
+ if isinstance(value, list):
185
+ return f"[{len(value)} items]" if value else "[]"
186
+ if isinstance(value, dict):
187
+ return json.dumps(value, default=str)
188
+ return str(value)
189
+
190
+
191
+ # Convenience functions for styled messages (equivalent to GTM CLI)
192
+ def success(message: str) -> None:
193
+ if not _quiet:
194
+ err_console.print(f"[green]OK[/green] {message}")
195
+
196
+
197
+ def error(message: str) -> None:
198
+ # Errors are NEVER suppressed, even in quiet mode
199
+ err_console.print(f"[red]Error:[/red] {message}")
200
+
201
+
202
+ def warn(message: str) -> None:
203
+ if not _quiet:
204
+ err_console.print(f"[yellow]Warning:[/yellow] {message}")
205
+
206
+
207
+ def info(message: str) -> None:
208
+ if not _quiet:
209
+ err_console.print(f"[blue]Info:[/blue] {message}")
@@ -0,0 +1,93 @@
1
+ """Pagination helpers.
2
+
3
+ Provides both:
4
+ - paginate_all(): API-level pagination (fetches all pages from Google API)
5
+ - paginate(): Client-side pagination (slices a local list for display)
6
+
7
+ Equivalent to GTM CLI's pagination.ts + paginateAll() in client.ts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, TypeVar
14
+
15
+ from ..config.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def paginate_all(
21
+ list_fn: Callable[..., Any],
22
+ result_key: str,
23
+ **kwargs: Any,
24
+ ) -> list:
25
+ """Paginate through all results from a Google API list operation.
26
+
27
+ Args:
28
+ list_fn: The API list method (e.g., admin.accounts().list)
29
+ result_key: The key in the response containing the items array
30
+ **kwargs: Additional arguments to pass to the list method
31
+
32
+ Returns:
33
+ All items across all pages.
34
+
35
+ Usage:
36
+ accounts = paginate_all(
37
+ lambda **kw: admin.accounts().list(**kw).execute(),
38
+ "accounts",
39
+ pageSize=200,
40
+ )
41
+ """
42
+ all_results = []
43
+ page_token = None
44
+
45
+ while True:
46
+ if page_token:
47
+ kwargs["pageToken"] = page_token
48
+
49
+ response = list_fn(**kwargs)
50
+ items = response.get(result_key, [])
51
+ all_results.extend(items)
52
+
53
+ page_token = response.get("nextPageToken")
54
+ if not page_token:
55
+ break
56
+
57
+ return all_results
58
+
59
+
60
+ @dataclass
61
+ class PaginatedResult:
62
+ """Result of client-side pagination."""
63
+ items: list
64
+ page: int
65
+ page_size: int
66
+ total_items: int
67
+ total_pages: int
68
+ has_next_page: bool
69
+ has_prev_page: bool
70
+
71
+
72
+ def paginate(items: list, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE) -> PaginatedResult:
73
+ """Client-side pagination of a local list.
74
+
75
+ Equivalent to GTM CLI's paginate() in pagination.ts.
76
+ """
77
+ page = max(1, page)
78
+ page_size = min(MAX_PAGE_SIZE, max(1, page_size))
79
+
80
+ total_items = len(items)
81
+ total_pages = max(1, (total_items + page_size - 1) // page_size)
82
+ start = (page - 1) * page_size
83
+ end = min(start + page_size, total_items)
84
+
85
+ return PaginatedResult(
86
+ items=items[start:end],
87
+ page=page,
88
+ page_size=page_size,
89
+ total_items=total_items,
90
+ total_pages=total_pages,
91
+ has_next_page=page < total_pages,
92
+ has_prev_page=page > 1,
93
+ )