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,155 @@
1
+ """Service account authentication.
2
+
3
+ Supports:
4
+ 1. GA_CLI_SERVICE_ACCOUNT env var (path to key file)
5
+ 2. GOOGLE_APPLICATION_CREDENTIALS env var (standard Google convention)
6
+ 3. Saved auth method from a previous ``ga auth login --service-account`` call
7
+
8
+ Equivalent to GTM CLI's auth/service-account.ts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from google.auth.transport.requests import Request
20
+ from google.oauth2 import service_account
21
+
22
+ from ..config.constants import OAUTH_SCOPES, get_auth_method_path, get_config_dir
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def validate_service_account_key(key_path: str) -> dict:
28
+ """Validate a service account key file.
29
+
30
+ Checks that the JSON is valid and contains required fields.
31
+ Returns the parsed key data.
32
+
33
+ Raises:
34
+ FileNotFoundError: If the key file does not exist.
35
+ ValueError: If the file is not valid service account JSON.
36
+ """
37
+ path = Path(key_path)
38
+
39
+ if not path.exists():
40
+ raise FileNotFoundError(f"Service account key file not found: {key_path}")
41
+
42
+ try:
43
+ data = json.loads(path.read_text())
44
+ except json.JSONDecodeError as exc:
45
+ raise ValueError(
46
+ f"Invalid JSON in service account key file: {key_path}"
47
+ ) from exc
48
+
49
+ if data.get("type") != "service_account":
50
+ raise ValueError(
51
+ f"Invalid key file: expected type 'service_account', "
52
+ f"got '{data.get('type', '<missing>')}'"
53
+ )
54
+
55
+ missing = [f for f in ("private_key", "client_email") if not data.get(f)]
56
+ if missing:
57
+ raise ValueError(
58
+ f"Invalid key file: missing required fields: {', '.join(missing)}"
59
+ )
60
+
61
+ return data
62
+
63
+
64
+ def login_with_service_account(key_path: str) -> str:
65
+ """Login with a service account key file.
66
+
67
+ Validates the key, tests authentication, and saves the auth method.
68
+ Returns the service account email.
69
+
70
+ Raises:
71
+ FileNotFoundError: If the key file does not exist.
72
+ ValueError: If the key file is invalid.
73
+ google.auth.exceptions.RefreshError: If authentication fails.
74
+ """
75
+ key_data = validate_service_account_key(key_path)
76
+
77
+ # Test that we can actually get a token
78
+ creds = service_account.Credentials.from_service_account_file(
79
+ key_path,
80
+ scopes=OAUTH_SCOPES,
81
+ )
82
+ creds.refresh(Request())
83
+
84
+ # Save auth method config
85
+ _save_auth_method(
86
+ {
87
+ "method": "service-account",
88
+ "service_account_path": str(Path(key_path).resolve()),
89
+ "service_account_email": key_data["client_email"],
90
+ }
91
+ )
92
+
93
+ return key_data["client_email"]
94
+
95
+
96
+ def get_service_account_credentials() -> Optional[service_account.Credentials]:
97
+ """Get service account credentials if configured.
98
+
99
+ Check order:
100
+ 1. GA_CLI_SERVICE_ACCOUNT env var
101
+ 2. GOOGLE_APPLICATION_CREDENTIALS env var
102
+ 3. Saved auth method from previous login
103
+
104
+ Returns None if no service account is configured (OAuth should be used).
105
+ """
106
+ # Check env vars first
107
+ for env_var in ("GA_CLI_SERVICE_ACCOUNT", "GOOGLE_APPLICATION_CREDENTIALS"):
108
+ key_path = os.environ.get(env_var)
109
+ if key_path:
110
+ validate_service_account_key(key_path)
111
+ return service_account.Credentials.from_service_account_file(
112
+ key_path,
113
+ scopes=OAUTH_SCOPES,
114
+ )
115
+
116
+ # Check saved auth method
117
+ auth_method = _load_auth_method()
118
+ if auth_method and auth_method.get("method") == "service-account":
119
+ key_path = auth_method.get("service_account_path")
120
+ if key_path:
121
+ validate_service_account_key(key_path)
122
+ return service_account.Credentials.from_service_account_file(
123
+ key_path,
124
+ scopes=OAUTH_SCOPES,
125
+ )
126
+
127
+ return None
128
+
129
+
130
+ def _save_auth_method(config: dict) -> None:
131
+ """Save auth method configuration."""
132
+ config_dir = get_config_dir()
133
+ config_dir.mkdir(parents=True, exist_ok=True)
134
+ get_auth_method_path().write_text(json.dumps(config, indent=2))
135
+
136
+
137
+ def _load_auth_method() -> Optional[dict]:
138
+ """Load auth method configuration."""
139
+ try:
140
+ return json.loads(get_auth_method_path().read_text())
141
+ except (FileNotFoundError, json.JSONDecodeError):
142
+ return None
143
+
144
+
145
+ def clear_auth_method() -> None:
146
+ """Delete auth method configuration."""
147
+ try:
148
+ get_auth_method_path().unlink()
149
+ except FileNotFoundError:
150
+ pass
151
+
152
+
153
+ def load_auth_method() -> Optional[dict]:
154
+ """Public access to load auth method (used by auth status command)."""
155
+ return _load_auth_method()
File without changes
@@ -0,0 +1,254 @@
1
+ """Access binding management commands."""
2
+
3
+ from typing import Optional
4
+
5
+ import questionary
6
+ import typer
7
+
8
+ from ..api.client import get_admin_alpha_client
9
+ from ..config.store import get_effective_value
10
+ from ..utils import handle_error, info, output, resolve_output_format, success
11
+ from ..utils.pagination import paginate_all
12
+
13
+ access_bindings_app = typer.Typer(
14
+ name="access-bindings",
15
+ help="Manage access bindings (user-role assignments)",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+
20
+ def _resolve_parent(
21
+ account_id: Optional[str], property_id: Optional[str]
22
+ ) -> tuple[str, str]:
23
+ """Resolve the parent resource for access binding commands.
24
+
25
+ Exactly one of account_id or property_id must be provided.
26
+ property_id falls back to config default if not explicitly set.
27
+
28
+ Returns (parent_string, parent_type) where parent_type is
29
+ "accounts" or "properties".
30
+ """
31
+ if account_id and property_id:
32
+ raise typer.BadParameter(
33
+ "Provide either --account-id or --property-id, not both."
34
+ )
35
+ if account_id:
36
+ return f"accounts/{account_id}", "accounts"
37
+
38
+ # Only fall back to config default when --account-id is not provided
39
+ effective_property = get_effective_value(property_id, "default_property_id")
40
+ if effective_property:
41
+ return f"properties/{effective_property}", "properties"
42
+ raise typer.BadParameter(
43
+ "Either --account-id or --property-id is required."
44
+ )
45
+
46
+
47
+ def _get_access_bindings_resource(admin, parent_type: str):
48
+ """Get the correct accessBindings API resource for the parent type."""
49
+ if parent_type == "accounts":
50
+ return admin.accounts().accessBindings()
51
+ return admin.properties().accessBindings()
52
+
53
+
54
+ def _format_roles(roles: list[str]) -> str:
55
+ """Strip predefinedRoles/ prefix for display."""
56
+ return ", ".join(r.removeprefix("predefinedRoles/") for r in roles)
57
+
58
+
59
+ @access_bindings_app.command("list")
60
+ def list_cmd(
61
+ account_id: Optional[str] = typer.Option(
62
+ None, "--account-id", "-a", help="Account ID"
63
+ ),
64
+ property_id: Optional[str] = typer.Option(
65
+ None, "--property-id", "-p", help="Property ID (numeric)"
66
+ ),
67
+ output_format: Optional[str] = typer.Option(
68
+ None, "--output", "-o", help="Output format (json, table, compact)"
69
+ ),
70
+ ):
71
+ """List access bindings for an account or property."""
72
+ try:
73
+ parent, parent_type = _resolve_parent(account_id, property_id)
74
+ effective_format = resolve_output_format(output_format)
75
+
76
+ admin = get_admin_alpha_client()
77
+ resource = _get_access_bindings_resource(admin, parent_type)
78
+ bindings = paginate_all(
79
+ lambda **kw: resource.list(parent=parent, **kw).execute(),
80
+ "accessBindings",
81
+ pageSize=500,
82
+ )
83
+
84
+ if effective_format != "json":
85
+ for b in bindings:
86
+ if "roles" in b:
87
+ b["_roles_display"] = _format_roles(b["roles"])
88
+
89
+ output(
90
+ bindings,
91
+ effective_format,
92
+ columns=["name", "user", "_roles_display"],
93
+ headers=["Resource Name", "User", "Roles"],
94
+ )
95
+ except typer.BadParameter:
96
+ raise
97
+ except Exception as e:
98
+ handle_error(e)
99
+
100
+
101
+ @access_bindings_app.command("get")
102
+ def get_cmd(
103
+ account_id: Optional[str] = typer.Option(
104
+ None, "--account-id", "-a", help="Account ID"
105
+ ),
106
+ property_id: Optional[str] = typer.Option(
107
+ None, "--property-id", "-p", help="Property ID (numeric)"
108
+ ),
109
+ binding_id: str = typer.Option(
110
+ ..., "--binding-id", "-b", help="Access binding ID"
111
+ ),
112
+ output_format: Optional[str] = typer.Option(
113
+ None, "--output", "-o", help="Output format (json, table, compact)"
114
+ ),
115
+ ):
116
+ """Get details for an access binding."""
117
+ try:
118
+ parent, parent_type = _resolve_parent(account_id, property_id)
119
+ effective_format = resolve_output_format(output_format)
120
+
121
+ admin = get_admin_alpha_client()
122
+ resource = _get_access_bindings_resource(admin, parent_type)
123
+ resource_name = f"{parent}/accessBindings/{binding_id}"
124
+ binding = resource.get(name=resource_name).execute()
125
+ output(binding, effective_format)
126
+ except typer.BadParameter:
127
+ raise
128
+ except Exception as e:
129
+ handle_error(e)
130
+
131
+
132
+ @access_bindings_app.command("create")
133
+ def create_cmd(
134
+ account_id: Optional[str] = typer.Option(
135
+ None, "--account-id", "-a", help="Account ID"
136
+ ),
137
+ property_id: Optional[str] = typer.Option(
138
+ None, "--property-id", "-p", help="Property ID (numeric)"
139
+ ),
140
+ user: str = typer.Option(
141
+ ..., "--user", "-u", help="Email address of the user"
142
+ ),
143
+ roles: str = typer.Option(
144
+ ..., "--roles", "-r", help="Comma-separated roles (e.g. viewer,editor)"
145
+ ),
146
+ output_format: Optional[str] = typer.Option(
147
+ None, "--output", "-o", help="Output format (json, table, compact)"
148
+ ),
149
+ ):
150
+ """Create an access binding for a user."""
151
+ try:
152
+ parent, parent_type = _resolve_parent(account_id, property_id)
153
+ effective_format = resolve_output_format(output_format)
154
+
155
+ role_list = [
156
+ r.strip() if "/" in r.strip() else f"predefinedRoles/{r.strip()}"
157
+ for r in roles.split(",")
158
+ if r.strip()
159
+ ]
160
+
161
+ if not role_list:
162
+ raise typer.BadParameter("--roles must contain at least one role.")
163
+
164
+ body = {"user": user, "roles": role_list}
165
+
166
+ admin = get_admin_alpha_client()
167
+ resource = _get_access_bindings_resource(admin, parent_type)
168
+ binding = resource.create(parent=parent, body=body).execute()
169
+ output(binding, effective_format)
170
+ except typer.BadParameter:
171
+ raise
172
+ except Exception as e:
173
+ handle_error(e)
174
+
175
+
176
+ @access_bindings_app.command("update")
177
+ def update_cmd(
178
+ account_id: Optional[str] = typer.Option(
179
+ None, "--account-id", "-a", help="Account ID"
180
+ ),
181
+ property_id: Optional[str] = typer.Option(
182
+ None, "--property-id", "-p", help="Property ID (numeric)"
183
+ ),
184
+ binding_id: str = typer.Option(
185
+ ..., "--binding-id", "-b", help="Access binding ID"
186
+ ),
187
+ roles: str = typer.Option(
188
+ ..., "--roles", "-r", help="Comma-separated roles (e.g. viewer,editor)"
189
+ ),
190
+ output_format: Optional[str] = typer.Option(
191
+ None, "--output", "-o", help="Output format (json, table, compact)"
192
+ ),
193
+ ):
194
+ """Update roles for an access binding."""
195
+ try:
196
+ parent, parent_type = _resolve_parent(account_id, property_id)
197
+ effective_format = resolve_output_format(output_format)
198
+
199
+ role_list = [
200
+ r.strip() if "/" in r.strip() else f"predefinedRoles/{r.strip()}"
201
+ for r in roles.split(",")
202
+ if r.strip()
203
+ ]
204
+
205
+ if not role_list:
206
+ raise typer.BadParameter("--roles must contain at least one role.")
207
+
208
+ resource_name = f"{parent}/accessBindings/{binding_id}"
209
+ body = {"name": resource_name, "roles": role_list}
210
+
211
+ admin = get_admin_alpha_client()
212
+ resource = _get_access_bindings_resource(admin, parent_type)
213
+ binding = resource.patch(name=resource_name, body=body).execute()
214
+ output(binding, effective_format)
215
+ except typer.BadParameter:
216
+ raise
217
+ except Exception as e:
218
+ handle_error(e)
219
+
220
+
221
+ @access_bindings_app.command("delete")
222
+ def delete_cmd(
223
+ account_id: Optional[str] = typer.Option(
224
+ None, "--account-id", "-a", help="Account ID"
225
+ ),
226
+ property_id: Optional[str] = typer.Option(
227
+ None, "--property-id", "-p", help="Property ID (numeric)"
228
+ ),
229
+ binding_id: str = typer.Option(
230
+ ..., "--binding-id", "-b", help="Access binding ID"
231
+ ),
232
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
233
+ ):
234
+ """Delete an access binding."""
235
+ try:
236
+ parent, parent_type = _resolve_parent(account_id, property_id)
237
+
238
+ if not yes:
239
+ confirmed = questionary.confirm(
240
+ f"Delete access binding {binding_id}? This cannot be undone."
241
+ ).ask()
242
+ if not confirmed:
243
+ info("Cancelled.")
244
+ raise typer.Exit()
245
+
246
+ resource_name = f"{parent}/accessBindings/{binding_id}"
247
+ admin = get_admin_alpha_client()
248
+ resource = _get_access_bindings_resource(admin, parent_type)
249
+ resource.delete(name=resource_name).execute()
250
+ success(f"Access binding {binding_id} deleted.")
251
+ except (typer.BadParameter, typer.Exit):
252
+ raise
253
+ except Exception as e:
254
+ handle_error(e)
@@ -0,0 +1,201 @@
1
+ """Access report commands: who accessed what data and when.
2
+
3
+ Uses the Analytics Admin API v1beta runAccessReport endpoint.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..api.client import get_admin_client
11
+ from ..config.store import get_effective_value
12
+ from ..utils import console, handle_error, info, output, require_options, resolve_output_format
13
+
14
+ access_reports_app = typer.Typer(
15
+ name="access-reports",
16
+ help="Run data-access reports (who accessed what)",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ _DEFAULT_DIMENSIONS = "userEmail,epochTimeMicros"
21
+ _DEFAULT_METRICS = "accessCount"
22
+
23
+
24
+ def _build_access_report_body(
25
+ dimensions: str,
26
+ metrics: str,
27
+ start_date: str,
28
+ end_date: str,
29
+ limit: int,
30
+ offset: int,
31
+ include_all_users: bool,
32
+ expand_groups: bool,
33
+ ) -> dict:
34
+ """Build the request body for runAccessReport."""
35
+ body: dict = {
36
+ "dimensions": [{"dimensionName": d.strip()} for d in dimensions.split(",")],
37
+ "metrics": [{"metricName": m.strip()} for m in metrics.split(",")],
38
+ "dateRanges": [{"startDate": start_date, "endDate": end_date}],
39
+ "limit": limit,
40
+ }
41
+ if offset > 0:
42
+ body["offset"] = offset
43
+ if include_all_users:
44
+ body["includeAllUsers"] = True
45
+ if expand_groups:
46
+ body["expandGroups"] = True
47
+ return body
48
+
49
+
50
+ def _transform_access_rows(result: dict) -> tuple[list[dict], list[str], list[str]]:
51
+ """Transform an access report response into a list of dicts."""
52
+ dim_headers = [h.get("dimensionName", "") for h in result.get("dimensionHeaders", [])]
53
+ met_headers = [h.get("metricName", "") for h in result.get("metricHeaders", [])]
54
+
55
+ all_keys = dim_headers + met_headers
56
+ rows = []
57
+ for row in result.get("rows", []):
58
+ entry = {}
59
+ for i, name in enumerate(dim_headers):
60
+ entry[name] = row["dimensionValues"][i]["value"]
61
+ for i, name in enumerate(met_headers):
62
+ entry[name] = row["metricValues"][i]["value"]
63
+ rows.append(entry)
64
+
65
+ return rows, all_keys, all_keys
66
+
67
+
68
+ @access_reports_app.command("run-account")
69
+ def run_account_cmd(
70
+ account_id: Optional[str] = typer.Option(
71
+ None, "--account-id", "-a", help="Account ID (numeric)"
72
+ ),
73
+ dimensions: str = typer.Option(
74
+ _DEFAULT_DIMENSIONS, "--dimensions", "-d", help="Comma-separated dimension names"
75
+ ),
76
+ metrics: str = typer.Option(
77
+ _DEFAULT_METRICS, "--metrics", "-m", help="Comma-separated metric names"
78
+ ),
79
+ start_date: str = typer.Option("7daysAgo", "--start-date", help="Start date"),
80
+ end_date: str = typer.Option("today", "--end-date", help="End date"),
81
+ limit: int = typer.Option(10000, "--limit", "-l", help="Max rows to return"),
82
+ offset: int = typer.Option(0, "--offset", help="Row offset for pagination"),
83
+ include_all_users: bool = typer.Option(
84
+ False, "--include-all-users", help="Include users who never made an API call"
85
+ ),
86
+ expand_groups: bool = typer.Option(
87
+ False, "--expand-groups", help="Expand user group members (requires --include-all-users)"
88
+ ),
89
+ output_format: Optional[str] = typer.Option(
90
+ None, "--output", "-o", help="Output format (json, table, compact)"
91
+ ),
92
+ ):
93
+ """Run a data-access report for an account."""
94
+ try:
95
+ effective_account = get_effective_value(account_id, "default_account_id")
96
+ require_options({"account_id": effective_account}, ["account_id"])
97
+ effective_format = resolve_output_format(output_format)
98
+
99
+ body = _build_access_report_body(
100
+ dimensions,
101
+ metrics,
102
+ start_date,
103
+ end_date,
104
+ limit,
105
+ offset,
106
+ include_all_users,
107
+ expand_groups,
108
+ )
109
+
110
+ admin = get_admin_client()
111
+ result = (
112
+ admin.accounts()
113
+ .runAccessReport(
114
+ entity=f"accounts/{effective_account}",
115
+ body=body,
116
+ )
117
+ .execute()
118
+ )
119
+
120
+ rows, columns, headers = _transform_access_rows(result)
121
+ row_count = result.get("rowCount", len(rows))
122
+
123
+ if not rows:
124
+ info("No access data found.")
125
+ return
126
+
127
+ output(rows, effective_format, columns=columns, headers=headers)
128
+
129
+ if effective_format == "table" and row_count > 0:
130
+ console.print(f"\n[dim]{row_count} total rows[/dim]")
131
+
132
+ except Exception as e:
133
+ handle_error(e)
134
+
135
+
136
+ @access_reports_app.command("run-property")
137
+ def run_property_cmd(
138
+ property_id: Optional[str] = typer.Option(
139
+ None, "--property-id", "-p", help="Property ID (numeric)"
140
+ ),
141
+ dimensions: str = typer.Option(
142
+ _DEFAULT_DIMENSIONS, "--dimensions", "-d", help="Comma-separated dimension names"
143
+ ),
144
+ metrics: str = typer.Option(
145
+ _DEFAULT_METRICS, "--metrics", "-m", help="Comma-separated metric names"
146
+ ),
147
+ start_date: str = typer.Option("7daysAgo", "--start-date", help="Start date"),
148
+ end_date: str = typer.Option("today", "--end-date", help="End date"),
149
+ limit: int = typer.Option(10000, "--limit", "-l", help="Max rows to return"),
150
+ offset: int = typer.Option(0, "--offset", help="Row offset for pagination"),
151
+ include_all_users: bool = typer.Option(
152
+ False, "--include-all-users", help="Include users who never made an API call"
153
+ ),
154
+ expand_groups: bool = typer.Option(
155
+ False, "--expand-groups", help="Expand user group members (requires --include-all-users)"
156
+ ),
157
+ output_format: Optional[str] = typer.Option(
158
+ None, "--output", "-o", help="Output format (json, table, compact)"
159
+ ),
160
+ ):
161
+ """Run a data-access report for a property."""
162
+ try:
163
+ effective_property = get_effective_value(property_id, "default_property_id")
164
+ require_options({"property_id": effective_property}, ["property_id"])
165
+ effective_format = resolve_output_format(output_format)
166
+
167
+ body = _build_access_report_body(
168
+ dimensions,
169
+ metrics,
170
+ start_date,
171
+ end_date,
172
+ limit,
173
+ offset,
174
+ include_all_users,
175
+ expand_groups,
176
+ )
177
+
178
+ admin = get_admin_client()
179
+ result = (
180
+ admin.properties()
181
+ .runAccessReport(
182
+ entity=f"properties/{effective_property}",
183
+ body=body,
184
+ )
185
+ .execute()
186
+ )
187
+
188
+ rows, columns, headers = _transform_access_rows(result)
189
+ row_count = result.get("rowCount", len(rows))
190
+
191
+ if not rows:
192
+ info("No access data found.")
193
+ return
194
+
195
+ output(rows, effective_format, columns=columns, headers=headers)
196
+
197
+ if effective_format == "table" and row_count > 0:
198
+ console.print(f"\n[dim]{row_count} total rows[/dim]")
199
+
200
+ except Exception as e:
201
+ handle_error(e)
@@ -0,0 +1,68 @@
1
+ """Account summaries command."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from ..api.client import get_admin_client
8
+ from ..utils import handle_error, output, resolve_output_format
9
+ from ..utils.pagination import paginate_all
10
+
11
+ account_summaries_app = typer.Typer(
12
+ name="account-summaries",
13
+ help="View account summaries",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @account_summaries_app.command("list")
19
+ def list_cmd(
20
+ output_format: Optional[str] = typer.Option(
21
+ None, "--output", "-o", help="Output format (json, table, compact)"
22
+ ),
23
+ ):
24
+ """List all accounts with their property summaries."""
25
+ try:
26
+ effective_format = resolve_output_format(output_format)
27
+
28
+ admin = get_admin_client()
29
+ summaries = paginate_all(
30
+ lambda **kw: admin.accountSummaries().list(**kw).execute(),
31
+ "accountSummaries",
32
+ pageSize=200,
33
+ )
34
+
35
+ if effective_format != "table":
36
+ output(summaries, effective_format)
37
+ return
38
+
39
+ # Flatten into rows: one row per property
40
+ rows = []
41
+ for acct in summaries:
42
+ acct_name = acct.get("displayName", "")
43
+ acct_id = acct.get("account", "").replace("accounts/", "")
44
+ for prop in acct.get("propertySummaries", []):
45
+ rows.append({
46
+ "accountName": acct_name,
47
+ "accountId": acct_id,
48
+ "propertyName": prop.get("displayName", ""),
49
+ "propertyId": prop.get("property", "").replace("properties/", ""),
50
+ "propertyType": prop.get("propertyType", ""),
51
+ })
52
+ if not acct.get("propertySummaries"):
53
+ rows.append({
54
+ "accountName": acct_name,
55
+ "accountId": acct_id,
56
+ "propertyName": "",
57
+ "propertyId": "",
58
+ "propertyType": "",
59
+ })
60
+
61
+ output(
62
+ rows,
63
+ effective_format,
64
+ columns=["accountName", "accountId", "propertyName", "propertyId", "propertyType"],
65
+ headers=["Account Name", "Account ID", "Property Name", "Property ID", "Property Type"],
66
+ )
67
+ except Exception as e:
68
+ handle_error(e)