google-analytics-cli 0.3.0__tar.gz → 0.4.0__tar.gz

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 (119) hide show
  1. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/PKG-INFO +10 -1
  2. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/README.md +9 -0
  3. google_analytics_cli-0.4.0/SPEC.md +440 -0
  4. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/pyproject.toml +1 -1
  5. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/agent_cmd.py +7 -1
  6. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/reports.py +107 -2
  7. google_analytics_cli-0.4.0/tasks/plan.md +283 -0
  8. google_analytics_cli-0.4.0/tasks/todo.md +56 -0
  9. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_reports.py +323 -0
  10. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_reports_batch.py +60 -0
  11. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_reports_pivot.py +51 -0
  12. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/uv.lock +1 -1
  13. google_analytics_cli-0.3.0/SPEC.md +0 -462
  14. google_analytics_cli-0.3.0/tasks/plan.md +0 -379
  15. google_analytics_cli-0.3.0/tasks/todo.md +0 -83
  16. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.api-snapshots/analyticsadmin_v1alpha.json +0 -0
  17. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.api-snapshots/analyticsadmin_v1beta.json +0 -0
  18. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.api-snapshots/analyticsdata_v1alpha.json +0 -0
  19. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.api-snapshots/analyticsdata_v1beta.json +0 -0
  20. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.github/workflows/api-watch.yml +0 -0
  21. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  22. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.github/workflows/release.yml +0 -0
  23. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.gitignore +0 -0
  24. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/.python-version +0 -0
  25. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/install.sh +0 -0
  26. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/scripts/check_api_changes.py +0 -0
  27. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/__init__.py +0 -0
  28. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/api/__init__.py +0 -0
  29. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/api/client.py +0 -0
  30. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/auth/__init__.py +0 -0
  31. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/auth/credentials.py +0 -0
  32. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/auth/oauth.py +0 -0
  33. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/auth/service_account.py +0 -0
  34. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/__init__.py +0 -0
  35. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/access_bindings.py +0 -0
  36. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/access_reports.py +0 -0
  37. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/account_summaries.py +0 -0
  38. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/accounts.py +0 -0
  39. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/annotations.py +0 -0
  40. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/audiences.py +0 -0
  41. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/auth_cmd.py +0 -0
  42. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/bigquery_links.py +0 -0
  43. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/calculated_metrics.py +0 -0
  44. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/channel_groups.py +0 -0
  45. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/completions_cmd.py +0 -0
  46. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/config_cmd.py +0 -0
  47. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/custom_dimensions.py +0 -0
  48. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/custom_metrics.py +0 -0
  49. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/data_retention.py +0 -0
  50. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/data_streams.py +0 -0
  51. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/enhanced_measurement.py +0 -0
  52. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/event_create_rules.py +0 -0
  53. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/event_edit_rules.py +0 -0
  54. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/firebase_links.py +0 -0
  55. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/google_ads_links.py +0 -0
  56. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/key_events.py +0 -0
  57. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/mp_secrets.py +0 -0
  58. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/properties.py +0 -0
  59. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/property_settings.py +0 -0
  60. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/upgrade_cmd.py +0 -0
  61. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/commands/user_provided_data.py +0 -0
  62. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/config/__init__.py +0 -0
  63. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/config/chat_session.py +0 -0
  64. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/config/constants.py +0 -0
  65. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/config/store.py +0 -0
  66. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/main.py +0 -0
  67. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/__init__.py +0 -0
  68. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/describe.py +0 -0
  69. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/dry_run.py +0 -0
  70. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/errors.py +0 -0
  71. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/filters.py +0 -0
  72. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/output.py +0 -0
  73. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/src/ga_cli/utils/pagination.py +0 -0
  74. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/__init__.py +0 -0
  75. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/conftest.py +0 -0
  76. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_access_bindings.py +0 -0
  77. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_access_reports.py +0 -0
  78. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_account_summaries.py +0 -0
  79. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_accounts.py +0 -0
  80. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_agent_cmd.py +0 -0
  81. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_annotations.py +0 -0
  82. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_api_client.py +0 -0
  83. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_api_diff.py +0 -0
  84. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_audiences.py +0 -0
  85. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_auth_cmd.py +0 -0
  86. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_bigquery_links.py +0 -0
  87. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_calculated_metrics.py +0 -0
  88. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_channel_groups.py +0 -0
  89. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_chat_session.py +0 -0
  90. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_completions_cmd.py +0 -0
  91. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_config_cmd.py +0 -0
  92. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_credentials.py +0 -0
  93. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_custom_dimensions.py +0 -0
  94. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_custom_metrics.py +0 -0
  95. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_data_retention.py +0 -0
  96. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_data_streams.py +0 -0
  97. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_describe.py +0 -0
  98. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_dry_run.py +0 -0
  99. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_enhanced_measurement.py +0 -0
  100. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_errors.py +0 -0
  101. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_event_create_rules.py +0 -0
  102. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_event_edit_rules.py +0 -0
  103. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_filters.py +0 -0
  104. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_firebase_links.py +0 -0
  105. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_google_ads_links.py +0 -0
  106. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_install_script.sh +0 -0
  107. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_key_events.py +0 -0
  108. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_mp_secrets.py +0 -0
  109. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_output.py +0 -0
  110. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_pagination.py +0 -0
  111. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_properties.py +0 -0
  112. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_property_settings.py +0 -0
  113. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_quiet_nocolor.py +0 -0
  114. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_reports_chat.py +0 -0
  115. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_reports_funnel.py +0 -0
  116. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_service_account.py +0 -0
  117. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_upgrade_cmd.py +0 -0
  118. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_user_provided_data.py +0 -0
  119. {google_analytics_cli-0.3.0 → google_analytics_cli-0.4.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: google-analytics-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Command-line interface for Google Analytics 4
5
5
  Project-URL: Homepage, https://github.com/daidalytics/google-analytics-cli
6
6
  Project-URL: Repository, https://github.com/daidalytics/google-analytics-cli
@@ -251,6 +251,15 @@ ga accounts list --output table # Table output (default)
251
251
  ga accounts list --output compact # Minimal ID + name output
252
252
  ```
253
253
 
254
+ > **Breaking change (post-0.3.0):** `ga reports run` and `ga reports build` with
255
+ > `-o json` return a `{"rows": [...], "metadata": {...}}` envelope instead of a
256
+ > bare rows array. `metadata` is the API's
257
+ > [`ResponseMetaData`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/ResponseMetaData)
258
+ > — sampling, thresholding, and data-truncation information that was previously
259
+ > discarded. In table output the same information renders as a **Data Notes**
260
+ > section under the results whenever present; unaffected reports look exactly as
261
+ > before.
262
+
254
263
  ## Chat
255
264
 
256
265
  Ask questions about a property in plain language:
@@ -219,6 +219,15 @@ ga accounts list --output table # Table output (default)
219
219
  ga accounts list --output compact # Minimal ID + name output
220
220
  ```
221
221
 
222
+ > **Breaking change (post-0.3.0):** `ga reports run` and `ga reports build` with
223
+ > `-o json` return a `{"rows": [...], "metadata": {...}}` envelope instead of a
224
+ > bare rows array. `metadata` is the API's
225
+ > [`ResponseMetaData`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/ResponseMetaData)
226
+ > — sampling, thresholding, and data-truncation information that was previously
227
+ > discarded. In table output the same information renders as a **Data Notes**
228
+ > section under the results whenever present; unaffected reports look exactly as
229
+ > before.
230
+
222
231
  ## Chat
223
232
 
224
233
  Ask questions about a property in plain language:
@@ -0,0 +1,440 @@
1
+ # Spec: Surface `ResponseMetaData` in report commands
2
+
3
+ Status: **Implemented — pending release** (branch `feat/response-metadata`)
4
+ Target version: 0.4.0 (tentative)
5
+ API: `analyticsdata` v1beta / v1alpha, revision `20260909`
6
+
7
+ ---
8
+
9
+ ## Objective
10
+
11
+ `reports run`, `reports build`, `reports batch`, and `reports pivot` all call GA4 Data API
12
+ methods whose response carries a `ResponseMetaData` (aka `metadata`) object — sampling info,
13
+ thresholding flags, schema restrictions, empty-report reasons, and (newly, as of the 2026-09-09
14
+ API revision) `dataTruncationReasons`. Today this object is **silently discarded**:
15
+
16
+ - `run` and `build` output only the transformed `rows` array in every format, including
17
+ `--output json` (`reports.py:334`, `:1517`) — there is currently no way to see this metadata
18
+ at all through those two commands.
19
+ - `batch` and `pivot` already pass the raw API response through for `--output json`, but their
20
+ table-mode rendering shows nothing for it.
21
+
22
+ **Users:**
23
+ 1. **Humans at a terminal** running `ga reports run` — should see a plain-language warning when
24
+ their numbers are sampled, thresholded, restricted, or truncated, without having to guess.
25
+ 2. **AI agents / scripts** consuming `-o json` — should be able to detect these conditions
26
+ programmatically instead of silently treating partial/sampled data as complete.
27
+
28
+ **Trigger:** the 2026-09-09 GA4 Discovery revision added `DataTruncationDateRange` and
29
+ `DataTruncationReason` schemas and a `dataTruncationReasons` array on `ResponseMetaData`
30
+ (`analyticsdata` v1beta/v1alpha only — `analyticsadmin` had a revision bump with no schema
31
+ diff). `.api-snapshots/analyticsdata_v1beta.json` and `_v1alpha.json` were already refreshed to
32
+ this revision in commit `5680e22`; **no `--update` run is needed for this work.**
33
+
34
+ **Success looks like:** running a report against a sampled or thresholded property shows a
35
+ metadata section under the results (table mode) or a `metadata` key in the JSON response,
36
+ without requiring any new flag.
37
+
38
+ ### Assumptions
39
+
40
+ 1. `reports realtime`'s response (`RunRealtimeReportResponse`) has **no `metadata` field** —
41
+ confirmed against the v1beta snapshot. Real-time is out of scope for this feature entirely.
42
+ 2. `reports funnel` (v1alpha `runFunnelReport`) is **not investigated** here — its response shape
43
+ (`funnelTable`) was not checked for a `metadata` field. Treated as out of scope unless a
44
+ follow-up confirms it needs the same treatment.
45
+ 3. `reports check-compatibility` and `reports metadata` (the `getMetadata` endpoint, unrelated to
46
+ `ResponseMetaData` despite the name collision) are out of scope — neither returns a report.
47
+ 4. `reports chat` is unrelated (separate `ChatResponse` shape, already raw-passthrough for JSON)
48
+ and untouched by this change.
49
+ 5. Changing `run`/`build`'s JSON shape from a bare array to `{rows, metadata}` is an accepted
50
+ **breaking change** for any existing script parsing `ga reports run -o json` as a flat array.
51
+ This is a pre-1.0 CLI (`0.3.0`), so the project's version-bump convention (not semver-strict
52
+ back-compat) applies; the change ships with a version bump and a release-notes callout.
53
+ 6. Metadata display in table/compact mode is **automatic** whenever the API returns a non-empty
54
+ `metadata` object — no new flag, consistent with how `_display_aggregations` already works
55
+ whenever `--metric-aggregation` is passed. (Unlike `propertyQuota`, metadata isn't gated by a
56
+ request-side opt-in flag on the GA4 API — the API returns it unconditionally when applicable.)
57
+ 7. `batch`'s per-sub-report metadata (each item in `RunReportResponse.metadata`) is displayed
58
+ per sub-report, directly under that sub-report's `--- Report N ---` block — consistent with
59
+ how `batch` already repeats `_display_aggregations`-style output per sub-report today (it
60
+ doesn't currently, but `row_count` is shown per sub-report at `reports.py:808`).
61
+ → Correct any of these now or I'll proceed with them.
62
+
63
+ ---
64
+
65
+ ## API Surface (from `.api-snapshots/analyticsdata_v1beta.json`)
66
+
67
+ ```jsonc
68
+ // ResponseMetaData (RunReportResponse.metadata / RunPivotReportResponse.metadata)
69
+ {
70
+ "samplingMetadatas": [ SamplingMetadata ], // one per date range, only if sampled
71
+ "schemaRestrictionResponse": SchemaRestrictionResponse,
72
+ "subjectToThresholding": boolean,
73
+ "dataTruncationReasons": [ DataTruncationReason ], // NEW in 2026-09-09 revision
74
+ "emptyReason": string // enum-like; only set when report is empty
75
+ }
76
+
77
+ // SamplingMetadata (per date range)
78
+ { "samplingSpaceSize": string /* int64 */, "samplesReadCount": string /* int64 */ }
79
+
80
+ // SchemaRestrictionResponse
81
+ { "activeMetricRestrictions": [
82
+ { "metricName": string, "restrictedMetricTypes": ["COST_DATA" | "REVENUE_DATA" | ...] }
83
+ ] }
84
+
85
+ // DataTruncationReason (the field driving this spec)
86
+ {
87
+ "dataTruncationType": "DATA_TRUNCATION_TYPE_RULES_BASED_MODELS" | "...DATA_DRIVEN_ATTRIBUTION"
88
+ | "...DV360" | "...CM360" | "...ITEM_SCOPED_ECOMMERCE_METRICS"
89
+ | "...EVENT_SCOPED_ECOMMERCE_METRICS" | "...DATE_RANGE" | "...PROPERTY"
90
+ | "...CONVERSIONS" | "...GOOGLE_ADS" | "...UNSPECIFIED",
91
+ "dataTruncationMessage": string,
92
+ "dataTruncationDate": string, // YYYY-MM-DD — data before this date is truncated
93
+ "dataTruncationDateRanges": [ { "startDate": string, "endDate": string } ]
94
+ }
95
+ ```
96
+
97
+ Notes that drive the design:
98
+ - `RunReportResponse.metadata` and `RunPivotReportResponse.metadata` both `$ref` the same
99
+ `ResponseMetaData` schema — one rendering helper covers `run`, `build`, `batch`, and `pivot`.
100
+ - Every field on `ResponseMetaData` is optional/absent when not applicable. The common case
101
+ (a small, unsampled, unrestricted, untruncated report) has an empty or near-empty `metadata`
102
+ object — the display helper must render **nothing** in that case, not an empty section.
103
+ - `dataTruncationReasons` can contain multiple entries (e.g. both a `DATE_RANGE` truncation and a
104
+ `GOOGLE_ADS` retention truncation on the same report) — render as a list, not a single line.
105
+ - `samplingMetadatas` is parallel to `dateRanges` by index, not named — if there are 2 date
106
+ ranges, expect at most 2 entries in the same order.
107
+
108
+ ---
109
+
110
+ ## Design Decisions
111
+
112
+ ### 1. One shared helper: `_display_response_metadata()`
113
+
114
+ New module-level function in `reports.py`, same shape as the existing `_display_quota()` /
115
+ `_display_aggregations()`:
116
+
117
+ ```python
118
+ def _display_response_metadata(metadata: dict | None, effective_format: str) -> None:
119
+ """Render sampling/thresholding/restriction/truncation warnings, if present.
120
+
121
+ Mirrors _display_quota(): a no-op when the API didn't return anything
122
+ interesting, so unaffected reports see no new output.
123
+ """
124
+ ```
125
+
126
+ Called from `run_cmd`, `build_cmd` (after the existing `_display_quota` call), from `batch_cmd`
127
+ once per sub-report, and from `pivot_cmd`'s table-mode branch. Table/compact and JSON share the
128
+ same gating logic (render only if `metadata` is non-empty) but different output paths (see below).
129
+
130
+ ### 2. Table/compact rendering
131
+
132
+ Printed as a `[bold]Data Notes[/bold]` section (mirroring the existing `\n[bold]Aggregations[/bold]`
133
+ header at `reports.py:232`), after aggregations/quota, using `warn()`/`info()`-style lines rather
134
+ than a table — these are prose warnings, not tabular data:
135
+
136
+ ```
137
+ [dim]150 total rows[/dim]
138
+
139
+ Data Notes
140
+ ⚠ Truncated (DATE_RANGE): Query date range may not be fully served.
141
+ ⚠ Truncated (GOOGLE_ADS): Google Ads data is truncated due to a 36-month retention policy
142
+ (before 2023-09-01).
143
+ ⚠ Subject to data thresholds — some low-volume data may be withheld.
144
+ ℹ Sampled: 42,103 of 1,204,500 events analyzed (3.5%).
145
+ ```
146
+
147
+ Rendering rules:
148
+ - `dataTruncationReasons`: one line per entry, `dataTruncationType` humanized (strip the
149
+ `DATA_TRUNCATION_TYPE_` prefix, title-case) + `dataTruncationMessage`; append
150
+ `(before {dataTruncationDate})` when the date is present.
151
+ - `subjectToThresholding`: one fixed line when `true`. Omit entirely when `false`/absent — a
152
+ `false` is not noteworthy.
153
+ - `samplingMetadatas`: one line per entry with the computed percentage
154
+ (`samplesReadCount / samplingSpaceSize`), guarding divide-by-zero.
155
+ - `schemaRestrictionResponse.activeMetricRestrictions`: one line per restricted metric, e.g.
156
+ `⚠ Metric 'purchaseRevenue' restricted (REVENUE_DATA) — values withheld by your role.`
157
+ - `emptyReason`: shown as a single `info()` line when the report has zero rows and this is set —
158
+ replaces the current unconditional "No results found." with the API's actual reason where
159
+ available (table renderer's existing fallback stays for when it's absent).
160
+ - **compact format:** same content, one tab-free line per item to stderr via `warn()`/`info()`
161
+ (keeping stdout pipeable, matching how `chat`'s session ID and quota already go to stderr in
162
+ compact mode).
163
+
164
+ ### 3. JSON rendering — breaking shape change for `run`/`build`
165
+
166
+ `run_cmd` and `build_cmd` currently do:
167
+ ```python
168
+ output(rows, effective_format, columns=columns, headers=headers)
169
+ ```
170
+ which for JSON prints `rows` as a bare array. This becomes:
171
+ ```python
172
+ if effective_format == "json":
173
+ output({"rows": rows, "metadata": result.get("metadata", {})}, effective_format)
174
+ else:
175
+ output(rows, effective_format, columns=columns, headers=headers)
176
+ _display_response_metadata(result.get("metadata"), effective_format)
177
+ ```
178
+ Table/compact keep the existing flat-rows path (metadata rendered separately, as prose, not
179
+ merged into row data). Only the JSON shape changes: `{"rows": [...], "metadata": {...}}` instead
180
+ of a bare array.
181
+
182
+ `batch_cmd` and `pivot_cmd` already return the *entire* raw API response for `-o json`
183
+ (`output(result, effective_format); return` at `reports.py:525`, `:800`), which already includes
184
+ `metadata` — **no JSON-side change needed for those two**, only their table-mode branches gain
185
+ the new `_display_response_metadata()` call.
186
+
187
+ ### 4. `batch` — per-sub-report metadata
188
+
189
+ Each item in `BatchRunReportsResponse.reports` is itself a `RunReportResponse` with its own
190
+ `metadata`. In table mode, call `_display_response_metadata(report.get("metadata"), ...)` right
191
+ after that sub-report's row count line (`reports.py:808-809`), inside the existing per-report
192
+ loop — so metadata attaches visibly to the sub-report it describes, not lumped at the end.
193
+
194
+ ### 5. Why automatic, not flag-gated
195
+
196
+ `--return-property-quota` needs a flag because `propertyQuota` is only populated when the
197
+ *request* sets `returnPropertyQuota: true` — it's an opt-in on the wire. `ResponseMetaData`
198
+ fields have no such request-side toggle; the API includes them whenever applicable regardless of
199
+ what the CLI asks for. Gating display behind a new flag would mean users silently miss sampling/
200
+ truncation warnings unless they already knew to ask — the opposite of what this feature is for.
201
+ Precedent: `_display_aggregations()` already renders unconditionally whenever its trigger
202
+ condition (`metric_aggregations` truthy) is met, no separate display flag.
203
+
204
+ ---
205
+
206
+ ## Commands
207
+
208
+ No new flags or command signatures. Behavior changes only:
209
+
210
+ ```bash
211
+ ga reports run -p 123 -m sessions,totalRevenue -d date # table: may show "Data Notes"
212
+ ga reports run -p 123 -m sessions -o json # {"rows": [...], "metadata": {...}}
213
+ ga reports build -p 123 # same table-mode addition
214
+ ga reports batch -p 123 -c batch.json # per-sub-report "Data Notes"
215
+ ga reports pivot -p 123 -m sessions -d date --pivot-field date # table-mode addition
216
+ ```
217
+
218
+ ### Dev commands (unchanged)
219
+
220
+ ```bash
221
+ pytest
222
+ pytest tests/test_reports.py -v
223
+ ruff check src/ tests/
224
+ uv sync
225
+ uv build
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Project Structure
231
+
232
+ ```
233
+ src/ga_cli/
234
+ └── commands/reports.py # MODIFY — add _display_response_metadata(); wire into
235
+ # run_cmd, build_cmd, batch_cmd, pivot_cmd
236
+
237
+ tests/
238
+ └── test_reports.py # MODIFY — new metadata fixtures + assertions (see Testing Strategy)
239
+ ```
240
+
241
+ No new files, no new dependencies, no config/auth changes — this is a pure `reports.py` rendering
242
+ change plus a JSON-shape change on two existing commands.
243
+
244
+ ---
245
+
246
+ ## Code Style
247
+
248
+ Follows existing `reports.py` conventions: `_`-prefixed module-level helpers, `.get()` with
249
+ defaults throughout (API fields are optional), `console.print` with a leading `\n[bold]...[/bold]`
250
+ header for table sections, `warn()`/`info()` for compact/non-table prose.
251
+
252
+ ```python
253
+ _TRUNCATION_TYPE_PREFIX = "DATA_TRUNCATION_TYPE_"
254
+
255
+
256
+ def _humanize_truncation_type(raw: str) -> str:
257
+ """DATA_TRUNCATION_TYPE_GOOGLE_ADS -> Google Ads."""
258
+ stripped = raw.removeprefix(_TRUNCATION_TYPE_PREFIX)
259
+ return stripped.replace("_", " ").title()
260
+
261
+
262
+ def _display_response_metadata(metadata: dict | None, effective_format: str) -> None:
263
+ """Render sampling/thresholding/restriction/truncation notes, if present.
264
+
265
+ A no-op when metadata is empty or absent, matching _display_quota()'s
266
+ behavior for reports that don't trigger any of these conditions.
267
+ """
268
+ if not metadata:
269
+ return
270
+
271
+ lines: list[str] = []
272
+
273
+ for reason in metadata.get("dataTruncationReasons", []):
274
+ kind = _humanize_truncation_type(reason.get("dataTruncationType", ""))
275
+ message = reason.get("dataTruncationMessage", "")
276
+ date = reason.get("dataTruncationDate")
277
+ suffix = f" (before {date})" if date else ""
278
+ lines.append(f"Truncated ({kind}): {message}{suffix}")
279
+
280
+ if metadata.get("subjectToThresholding"):
281
+ lines.append("Subject to data thresholds — some low-volume data may be withheld.")
282
+
283
+ for sm in metadata.get("samplingMetadatas", []):
284
+ space = int(sm.get("samplingSpaceSize", 0) or 0)
285
+ read = int(sm.get("samplesReadCount", 0) or 0)
286
+ pct = f"{(read / space * 100):.1f}%" if space else "?"
287
+ lines.append(f"Sampled: {read:,} of {space:,} events analyzed ({pct}).")
288
+
289
+ for restriction in metadata.get("schemaRestrictionResponse", {}).get(
290
+ "activeMetricRestrictions", []
291
+ ):
292
+ types = ", ".join(restriction.get("restrictedMetricTypes", []))
293
+ lines.append(
294
+ f"Metric '{restriction.get('metricName')}' restricted ({types}) — "
295
+ "values withheld by your role."
296
+ )
297
+
298
+ if not lines:
299
+ return
300
+
301
+ if effective_format == "table":
302
+ console.print("\n[bold]Data Notes[/bold]")
303
+ for line in lines:
304
+ console.print(f" [yellow]![/yellow] {line}")
305
+ elif effective_format == "compact":
306
+ for line in lines:
307
+ warn(line)
308
+ ```
309
+
310
+ **Conventions this encodes:**
311
+ - `.get(..., [])` / `.get(..., {})` chains throughout — every `ResponseMetaData` field is
312
+ optional, matching the defensive style already used for `dimensionHeaders`/`metricHeaders` in
313
+ `_transform_report_rows`.
314
+ - Divide-by-zero guarded (`if space else "?"`) rather than assumed present, per `int64` fields
315
+ arriving as strings from the JSON API (`NumericValue`-style string-encoded integers elsewhere
316
+ in this schema family).
317
+ - No new Rich `Table` — a warnings list is prose, not tabular, unlike `_display_aggregations`.
318
+
319
+ ---
320
+
321
+ ## Testing Strategy
322
+
323
+ `pytest` + `typer.testing.CliRunner`, class-based grouping, all API calls mocked via
324
+ `unittest.mock.patch`, per existing `tests/test_reports.py` conventions. **No real API calls.**
325
+
326
+ New fixtures needed in `tests/test_reports.py`: a `SAMPLE_REPORT_RESPONSE_WITH_METADATA` (or
327
+ per-field variants) alongside the existing `SAMPLE_REPORT_RESPONSE`.
328
+
329
+ | Class | Cases |
330
+ |---|---|
331
+ | `TestResponseMetadataDisplay` | table mode: `dataTruncationReasons` renders one line per entry with type+message+date; `subjectToThresholding: true` renders its fixed line, `false`/absent renders nothing; `samplingMetadatas` renders a correct percentage, and handles `samplingSpaceSize: "0"` without a ZeroDivisionError; `activeMetricRestrictions` renders per-metric; empty/absent `metadata` renders **no** "Data Notes" section at all (regression guard against the always-on trigger from Design Decision 5) |
332
+ | `TestResponseMetadataJson` | `run -o json` returns `{"rows": [...], "metadata": {...}}` (not a bare array) — update the existing `test_run_json_output` assertion for the new envelope; `metadata` key present-but-empty (`{}`) when the API returns none; `build -o json`, `batch -o json`, `pivot -o json` already raw-passthrough — assert `metadata` key survives unchanged (regression guard, not new behavior) |
333
+ | `TestBatchPerReportMetadata` | two sub-reports, only the second has `dataTruncationReasons` — table output shows "Data Notes" attached to the second block only, not the first |
334
+ | `TestResponseMetadataCompact` | `-o compact` sends truncation/thresholding/sampling lines to stderr via `warn()`, stdout stays exactly the row-only output |
335
+
336
+ Also verify (no new test needed, but confirm during implementation): `test_run_default_metrics`
337
+ and other existing table-mode tests that use `SAMPLE_REPORT_RESPONSE` (no `metadata` key) still
338
+ pass unchanged — the no-op path must not alter output for responses that predate this field.
339
+
340
+ ---
341
+
342
+ ## Boundaries
343
+
344
+ **Always:**
345
+ - Run `pytest` and `ruff check src/ tests/` before committing.
346
+ - Mock every API call in tests; add fixtures rather than hitting a real property.
347
+ - Treat every `ResponseMetaData` sub-field as optional (`.get()` with a default), since GA4 only
348
+ populates fields that apply to that specific report.
349
+ - Keep the "no metadata → no output" no-op guarantee — this must be silent for the overwhelming
350
+ majority of reports that trigger none of these conditions.
351
+
352
+ **Ask first:**
353
+ - Adding `reports realtime` or `reports funnel` to this feature (both were scoped out above;
354
+ realtime's response has no `metadata` field at all, funnel wasn't investigated).
355
+ - Any dependency addition (none should be needed — this is stdlib dict traversal + existing Rich
356
+ console).
357
+ - Reformatting `_transform_report_rows`/`_transform_pivot_rows` beyond what's needed to thread
358
+ `metadata` through — this spec's diff should stay additive.
359
+ - Bumping the version or tagging a release.
360
+
361
+ **Never:**
362
+ - Merge `metadata` fields into individual row dicts — it describes the whole report, not any one
363
+ row, and must stay a sibling key (`{"rows": ..., "metadata": ...}`), not inlined per-row.
364
+ - Silently swallow the JSON shape change — the release notes/CHANGELOG must call out that
365
+ `ga reports run -o json` and `ga reports build -o json` now return an object, not a bare array.
366
+ - Run `scripts/check_api_changes.py --update` as part of this work — the snapshots are already
367
+ current (commit `5680e22`); re-running it is out of scope and risks picking up unrelated drift.
368
+
369
+ ---
370
+
371
+ ## Success Criteria
372
+
373
+ 1. `ga reports run` against a sampled property (or a mocked response with `samplingMetadatas`)
374
+ shows a "Data Notes" section with a correct percentage in table mode.
375
+ 2. `ga reports run -o json` against the same mocked response returns
376
+ `{"rows": [...], "metadata": {"samplingMetadatas": [...]}}`.
377
+ 3. `ga reports run` against an unsampled, unrestricted, untruncated property shows **no** "Data
378
+ Notes" section and JSON output has `"metadata": {}` — output for existing users is otherwise
379
+ unchanged.
380
+ 4. A mocked `dataTruncationReasons` response renders each reason as a separate, human-readable
381
+ line including the truncation date when present.
382
+ 5. `ga reports batch` with a two-report config where only one sub-report has metadata attaches
383
+ the "Data Notes" section to the correct sub-report block.
384
+ 6. `ga reports pivot -o json` and `ga reports batch -o json` continue to raw-passthrough
385
+ `metadata` unchanged (no regression from this change, since they already did).
386
+ 7. `pytest` and `ruff check src/ tests/` pass, including the new/updated cases in the table above.
387
+ 8. `ga reports run --help` / `ga reports build --help` require no changes (no new flags) but the
388
+ CHANGELOG documents the JSON shape change as breaking.
389
+
390
+ ---
391
+
392
+ ## Implementation Order
393
+
394
+ 1. **Shared helper** — `_display_response_metadata()` + `_humanize_truncation_type()` in
395
+ `reports.py`, unit-tested in isolation against hand-built `metadata` dicts covering each field
396
+ and the empty case. *Verify:* `TestResponseMetadataDisplay`.
397
+ 2. **Wire into `run_cmd`** — JSON envelope change + table-mode call. *Verify:*
398
+ `TestResponseMetadataJson` (run cases), update `test_run_json_output`.
399
+ 3. **Wire into `build_cmd`** — same pattern, reusing the helper. *Verify:* existing `build` tests
400
+ still pass; add a JSON+table case.
401
+ 4. **Wire into `batch_cmd`** — per-sub-report call inside the existing loop. *Verify:*
402
+ `TestBatchPerReportMetadata`.
403
+ 5. **Wire into `pivot_cmd`** — table-mode call only (JSON already passes through). *Verify:*
404
+ pivot table-mode test with a mocked `metadata` key.
405
+ 6. **Compact-mode pass + CHANGELOG entry** — `TestResponseMetadataCompact`; document the JSON
406
+ breaking change. *Verify:* full `pytest` + `ruff check src/ tests/`.
407
+
408
+ Steps 2–5 are independent once step 1 lands (each touches a different command) and could be done
409
+ in any order or in parallel; step 6 depends on all of them.
410
+
411
+ ---
412
+
413
+ ## Open Questions — all resolved
414
+
415
+ 1. ~~**`reports funnel` scope.**~~ **Resolved during planning:** the v1alpha snapshot shows
416
+ `RunFunnelReportResponse` has no `metadata` field at all (`funnelVisualization`, `kind`,
417
+ `propertyQuota`, `funnelTable`). Funnel is definitively out of scope.
418
+ 2. ~~**Percentage formatting.**~~ **Resolved:** one decimal place (`3.5%`), no existing
419
+ precedent to match.
420
+ 3. ~~**CHANGELOG mechanics.**~~ **Resolved:** breaking-change note lives in README's
421
+ "Output formats" section; the GitHub release notes carry it again at tag time. No
422
+ `CHANGELOG.md` introduced.
423
+
424
+ ## Implementation Notes (2026-09-18)
425
+
426
+ Deviations and findings recorded while implementing (`tasks/plan.md`, `tasks/todo.md`):
427
+
428
+ - **The live API populates `metadata` on every response** with `currencyCode` and `timeZone`
429
+ (verified against a real property) — real-world `metadata` is never an empty dict. The
430
+ no-op guarantee therefore rests on the helper's *rendered-lines* guard, not the empty-dict
431
+ check; pinned by `test_currency_and_timezone_only_render_no_data_notes`.
432
+ - **`emptyReason` renders as a regular Data Notes line**, not the separate `info()` line this
433
+ spec proposed — uniform with the other notes, and it composes naturally under the table
434
+ renderer's existing "No results found." fallback, which stays untouched.
435
+ - **The Code Style sketch above under-escapes:** interpolating API text directly into a
436
+ markup-styled `console.print` line would let a message containing `[bold]` be parsed as a
437
+ style tag. The implementation wraps API-sourced text in `rich.markup.escape()`; pinned by
438
+ `test_api_text_with_rich_markup_renders_literally`.
439
+ - Truncation-type humanization keeps words containing digits uppercase (`DV360`, `CM360`)
440
+ rather than title-casing them into `Dv360`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "google-analytics-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Command-line interface for Google Analytics 4"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -243,7 +243,7 @@ Requires both `--property-id` and `--stream-id`. `get` retrieves current setting
243
243
 
244
244
  ### Reports
245
245
  ```bash
246
- ga reports run [-p PROPERTY_ID] -m metrics -d dimensions --start-date DATE --end-date DATE [--limit N] [-o json] # pipe: | jq '[.[] | {date, sessions, totalUsers}]'
246
+ ga reports run [-p PROPERTY_ID] -m metrics -d dimensions --start-date DATE --end-date DATE [--limit N] [-o json] # pipe: | jq '[.rows[] | {date, sessions, totalUsers}]'
247
247
  ga reports pivot [-p PROPERTY_ID] -m metrics -d dimensions --pivot-field FIELD [--start-date DATE] [-o json]
248
248
  ga reports check-compatibility [-p PROPERTY_ID] [-m metrics] [-d dimensions] [-o json]
249
249
  ga reports metadata [-p PROPERTY_ID] [--type metrics|dimensions] [--search TEXT] [-o json]
@@ -252,6 +252,12 @@ ga reports chat "QUESTION" [-p PROPERTY_ID] [--session-id ID] [--continue] [-i]
252
252
  ```
253
253
  Dates: `today`, `yesterday`, `7daysAgo`, `30daysAgo`, `90daysAgo`, or `YYYY-MM-DD`
254
254
 
255
+ `reports run` JSON output is a `{rows, metadata}` envelope. `metadata` is the API's
256
+ `ResponseMetaData`: always `currencyCode`/`timeZone`, plus — when applicable —
257
+ `samplingMetadatas`, `subjectToThresholding`, and `dataTruncationReasons`. Check it
258
+ before treating report data as complete; `pivot`/`batch` return it in their raw
259
+ responses under the same key.
260
+
255
261
  ### Upgrade
256
262
  ```bash
257
263
  ga upgrade [--check] [--force]