google-ads-cli 0.0.1__tar.gz → 0.0.2__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 (42) hide show
  1. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/.gitignore +2 -2
  2. google_ads_cli-0.0.2/PKG-INFO +278 -0
  3. google_ads_cli-0.0.2/README.md +260 -0
  4. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/pyproject.toml +4 -4
  5. google_ads_cli-0.0.2/src/googleadscli/__init__.py +1 -0
  6. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/cli.py +13 -13
  7. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/client_factory.py +3 -3
  8. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/_common.py +2 -2
  9. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/accounts.py +9 -7
  10. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/auth.py +22 -22
  11. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/call.py +14 -14
  12. google_ads_cli-0.0.2/src/googleadscli/commands/fields.py +94 -0
  13. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/__init__.py +4 -4
  14. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/ad.py +4 -4
  15. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/ad_group.py +4 -4
  16. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/budget.py +3 -3
  17. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/campaign.py +8 -8
  18. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/keyword.py +4 -4
  19. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/mutate.py +8 -8
  20. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/query.py +4 -4
  21. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/config.py +30 -29
  22. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/errors.py +1 -1
  23. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/formatting.py +3 -3
  24. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/gaql.py +5 -5
  25. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/proto_bridge.py +35 -34
  26. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/utils.py +4 -4
  27. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/conftest.py +8 -8
  28. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_call_command.py +1 -1
  29. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_config.py +1 -1
  30. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_errors.py +1 -1
  31. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_fields_command.py +1 -1
  32. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_formatting.py +1 -1
  33. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_mutate_command.py +2 -2
  34. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_proto_bridge_call_resolution.py +1 -1
  35. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_proto_bridge_mutate_resolution.py +2 -2
  36. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_query_command.py +1 -1
  37. google_ads_cli-0.0.1/PKG-INFO +0 -212
  38. google_ads_cli-0.0.1/README.md +0 -194
  39. google_ads_cli-0.0.1/src/googleadscli/__init__.py +0 -1
  40. google_ads_cli-0.0.1/src/googleadscli/commands/fields.py +0 -94
  41. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/LICENSE +0 -0
  42. {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/__init__.py +0 -0
@@ -11,8 +11,8 @@ dist/
11
11
  google-ads.yaml
12
12
  .env
13
13
 
14
- # Google Cloud Service-Account-Schluesseldateien (enthalten private Keys) --
15
- # duerfen niemals committet werden.
14
+ # Google Cloud service account key files (contain private keys) --
15
+ # must never be committed.
16
16
  *service-account*.json
17
17
  moosmoos-marketing-os-*.json
18
18
 
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.5
2
+ Name: google-ads-cli
3
+ Version: 0.0.2
4
+ Summary: Full-featured command-line CLI for the Google Ads API (v25), built for use by AI agents
5
+ License: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: google-ads>=32.0.0
9
+ Requires-Dist: google-auth-oauthlib>=1.2
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: typer>=0.12
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.6; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # google-ads-cli (`gads`)
20
+
21
+ A fully-featured command-line CLI for the Google Ads API (currently v25),
22
+ built for control by AI agents: non-interactive, with JSON as the default
23
+ input/output format and clear exit codes.
24
+
25
+ ## Why no per-resource subcommands?
26
+
27
+ The Google Ads API has 100+ services and hundreds of resources. Instead of
28
+ writing dedicated code for every single resource, `gads` exploits the API's
29
+ consistent GAPIC naming convention via reflection. Three generic commands
30
+ therefore cover **the entire API**:
31
+
32
+ - `gads query` — arbitrary GAQL queries (reporting/reading)
33
+ - `gads mutate <resource>` — generic create/update/remove for every
34
+ mutable resource (`campaign`, `ad_group`, `ad_group_criterion`, ...)
35
+ - `gads call <Service> <Method>` — fallback for everything else (BatchJobService,
36
+ ConversionUploadService, OfflineUserDataJobService, KeywordPlanService,
37
+ GoogleAdsFieldService, CustomerService, long-running operations, ...)
38
+
39
+ On top of that there's a small number of convenient high-level commands under
40
+ `gads hl` (create campaign/budget/ad group/keyword/ad) as thin wrappers —
41
+ pure ergonomics, no additional coverage needed.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install google-ads-cli
47
+ ```
48
+
49
+ For local development (editable install with test/lint dependencies):
50
+
51
+ ```bash
52
+ python3 -m venv .venv && source .venv/bin/activate
53
+ pip install -e ".[dev]"
54
+ ```
55
+
56
+ ## Setup (one-time, fully non-interactive afterwards)
57
+
58
+ Two auth methods are supported:
59
+
60
+ ### Option A: Service Account (Google Cloud Console)
61
+
62
+ Recommended when API access is managed through a GCP project/service account
63
+ (no interactive consent flow needed):
64
+
65
+ ```bash
66
+ gads auth use-service-account \
67
+ --json-key-file-path /path/to/service-account.json \
68
+ --login-customer-id <MCC_CID> # optional, if accessing via an MCC
69
+ ```
70
+
71
+ Prerequisite: the `client_email` from the key file must be added as a user on
72
+ the Google Ads account (or MCC) under **Tools & Settings > Access and
73
+ Security > Users**. A classic developer token is not strictly required here
74
+ (Google Ads "Cloud-managed access" for projects linked to a Cloud
75
+ Organization) — settable optionally via `--developer-token` if you have one.
76
+ Domain-wide delegation (impersonating a Workspace user) is only needed if the
77
+ client_email wasn't added directly as an account user; set
78
+ `--impersonated-email` for that.
79
+
80
+ The key file itself should live **outside** the repo (e.g. under
81
+ `~/.config/google-ads-cli/`) — it contains a private key and must never be
82
+ committed.
83
+
84
+ ### Option B: OAuth Installed-App Flow
85
+
86
+ ```bash
87
+ gads auth login \
88
+ --client-id <OAUTH_CLIENT_ID> \
89
+ --client-secret <OAUTH_CLIENT_SECRET> \
90
+ --developer-token <DEVELOPER_TOKEN> \
91
+ --login-customer-id <MCC_CID> # optional
92
+ ```
93
+
94
+ Both commands store the configuration under
95
+ `~/.config/google-ads-cli/google-ads.yaml` (file permissions 600). All
96
+ subsequent commands then run fully non-interactively.
97
+
98
+ For containers/CI with no file at all: every field can be overridden via env
99
+ var (`GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_CLIENT_ID`,
100
+ `GOOGLE_ADS_CLIENT_SECRET`, `GOOGLE_ADS_REFRESH_TOKEN`,
101
+ `GOOGLE_ADS_JSON_KEY_FILE_PATH`, `GOOGLE_ADS_IMPERSONATED_EMAIL`,
102
+ `GOOGLE_ADS_USE_APPLICATION_DEFAULT_CREDENTIALS`, `GOOGLE_ADS_LOGIN_CUSTOMER_ID`).
103
+
104
+ ```bash
105
+ gads auth status # checks the config against the real API
106
+ ```
107
+
108
+ ## Usage
109
+
110
+ **GAQL query (reporting):**
111
+ ```bash
112
+ gads query -c 1234567890 -q "
113
+ SELECT campaign.id, campaign.name, metrics.clicks
114
+ FROM campaign
115
+ WHERE segments.date DURING LAST_7_DAYS"
116
+ ```
117
+
118
+ **Create a campaign (generic `mutate` command):**
119
+ ```bash
120
+ gads mutate campaign -c 1234567890 -o '[
121
+ {"create": {"name": "My Campaign", "status": "PAUSED",
122
+ "advertising_channel_type": "SEARCH",
123
+ "campaign_budget": "customers/1234567890/campaignBudgets/111"}}
124
+ ]'
125
+ ```
126
+
127
+ **Validate the same operation first, without applying it:**
128
+ ```bash
129
+ gads mutate campaign -c 1234567890 --dry-run -o '[...]'
130
+ ```
131
+
132
+ **Pause a campaign (update with automatic field mask):**
133
+ ```bash
134
+ gads mutate campaign -c 1234567890 -o '[
135
+ {"update": {"resource_name": "customers/1234567890/campaigns/999", "status": "PAUSED"}}
136
+ ]'
137
+ ```
138
+
139
+ **Remove (soft-delete) a campaign:**
140
+ ```bash
141
+ gads mutate campaign -c 1234567890 -o '[
142
+ {"remove": "customers/1234567890/campaigns/999"}
143
+ ]'
144
+ ```
145
+ `remove` doesn't delete the record outright — Google Ads sets its status to
146
+ `REMOVED` and keeps it for reporting history. A removed resource can't be
147
+ mutated again afterwards (`OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE`).
148
+
149
+ ### `mutate` operation schema
150
+
151
+ `--operations-json`/`-o` takes a JSON **array**; each element is one
152
+ operation and must contain **exactly one** of these three keys:
153
+
154
+ | Key | Value | Notes |
155
+ |---|---|---|
156
+ | `create` | object of field values (snake_case, matching the resource proto) | |
157
+ | `update` | object that **must include `resource_name`** plus the fields to change | if `update_mask` is omitted, it's derived automatically from the fields you set; pass it explicitly (a list of dotted field paths) to override |
158
+ | `remove` | a bare resource name string | no nested object, just the string |
159
+
160
+ `resource_name` follows the pattern
161
+ `customers/{customer_id}/{resourcePluralCamelCase}/{id}`, e.g.
162
+ `customers/1234567890/campaigns/999`,
163
+ `customers/1234567890/adGroupCriteria/222~333` (criteria use a composite
164
+ `adGroupId~criterionId` id). You get these back as `resource_name` in the
165
+ `results` of every `create` call, so you rarely need to construct them by
166
+ hand except for `update`/`remove` on records you already know about.
167
+
168
+ **Generic fallback for any other API method:**
169
+ ```bash
170
+ gads list-services
171
+ gads list-methods BatchJobService
172
+ gads call BatchJobService mutate -c 1234567890 -r '{"customer_id": "1234567890", "mutate_operation": [...]}'
173
+ ```
174
+
175
+ **Convenience commands:**
176
+ ```bash
177
+ gads hl budget create -c 1234567890 --name "Budget A" --amount-micros 5000000
178
+ gads hl campaign create -c 1234567890 --name "Campaign A" --budget customers/1234567890/campaignBudgets/111
179
+ gads hl keyword add -c 1234567890 --ad-group customers/.../adGroups/222 --text "buy shoes" --match-type BROAD
180
+ gads accounts list-hierarchy -c <MCC_CID>
181
+ ```
182
+
183
+ **Looking up field metadata** (which fields does a resource offer? no
184
+ customer account needed — a wrapper around `GoogleAdsFieldService`, the API's
185
+ global field catalog):
186
+ ```bash
187
+ gads fields list ad_group # all fields of ad_group
188
+ gads fields list ad_group --category METRIC # metrics only
189
+ gads fields list campaign.network_settings # fields of a sub-message
190
+ gads fields show ad_group.status # full metadata incl. enum_values, selectable_with
191
+ ```
192
+ Shows `selectable`/`filterable`/`sortable`/`enum_values`/`data_type` and (for
193
+ `show`) `selectable_with` (which other resources/segments/metrics the field
194
+ can be combined with in a GAQL query). This only tells you what's *readable*
195
+ via GAQL — not what's *writable* via `mutate`, or which
196
+ `advertising_channel_type` it's compatible with. That can only be discovered
197
+ by reading the resource proto or by trying a `--dry-run` (see "Field/schema
198
+ research beyond `gads fields`" below).
199
+
200
+ `gads fields` builds a query for `GoogleAdsFieldService`'s own mini query
201
+ language internally (not full GAQL). If you ever call that service directly
202
+ via `gads call GoogleAdsFieldService search_google_ads_fields -r '{"query": "..."}'`,
203
+ note its limitations: only `AND` is supported (**no `OR`**), string literals
204
+ need double quotes, and `LIKE` uses `%` as a wildcard — the same restrictions
205
+ apply to regular GAQL queries via `gads query`.
206
+
207
+ ## Agent contract
208
+
209
+ - Every command and subcommand is self-documenting: `gads --help`,
210
+ `gads mutate --help`, `gads hl campaign create --help`, etc. always reflect
211
+ the exact, current set of flags — treat that as authoritative over any
212
+ example in this README if the two ever disagree (e.g. after an update).
213
+ - Output: plain JSON on stdout (default; `--format table`/`--format csv` for
214
+ humans). Logs/errors go to stderr.
215
+ - Exit codes: `0` success, `1` CLI/validation error, `2` Google Ads API error
216
+ (structured JSON with `error_code`, `message`, `field_path`), `70`
217
+ unexpected internal error.
218
+ - Mutate commands run immediately by default (no confirmation prompt,
219
+ important for autonomous agents); `--dry-run` uses the API's
220
+ `validate_only`.
221
+
222
+ ## Field/schema research beyond `gads fields`
223
+
224
+ `gads fields` answers "what is selectable/filterable via GAQL?". For "what
225
+ fields actually exist for `mutate`, and what type/enum value do they
226
+ expect?", a short bit of Python introspection against the locally installed,
227
+ generated classes of the `google-ads` library also helps (purely local, no
228
+ API call, no credentials needed):
229
+
230
+ ```python
231
+ from google.ads.googleads.v25.resources.types.ad_group import AdGroup
232
+ for f in AdGroup.pb().DESCRIPTOR.fields:
233
+ print(f.name, [v.name for v in f.enum_type.values] if f.enum_type else f.type)
234
+ ```
235
+
236
+ For the third question — "is this field actually writable via `mutate` for a
237
+ given `advertising_channel_type`?" — there's no metadata source for that;
238
+ only a real `--dry-run` call reveals it (see the error codes
239
+ `OPERATION_NOT_PERMITTED_FOR_CONTEXT`, `IMMUTABLE_FIELD`,
240
+ `SETTING_TYPE_IS_NOT_COMPATIBLE_WITH_CAMPAIGN` in `errors.py`).
241
+
242
+ ## Common errors and gotchas
243
+
244
+ Found through actual live testing against the API (not exhaustive, but the
245
+ ones most likely to trip up a first attempt):
246
+
247
+ | Error code | What it means | What to do |
248
+ |---|---|---|
249
+ | `DATE_RANGE_ERROR_END_TIME_MUST_BE_THE_END_OF_A_DAY` | `campaign.end_date_time` (and similar `*_date_time` fields) must literally end in `" 23:59:59"` | Use e.g. `"2026-12-31 23:59:59"`, not just the date |
250
+ | `FIELD_HAS_SUBFIELDS` | You passed an explicit `update_mask` naming a message-type field (e.g. `"vanity_pharma"`) instead of its leaf sub-fields | Omit `update_mask` for that operation and let it be derived automatically, or list the actual leaf paths (e.g. `"vanity_pharma.vanity_pharma_text"`) |
251
+ | `IMMUTABLE_FIELD` | The field can only be set on `create`, not `update` (e.g. several `app_campaign_setting.*`/`travel_campaign_settings.*` fields) — or, on `ad_group_ad`, `start_date_time`/`end_date_time` can't be set at all | Set it during `create` instead, or drop it |
252
+ | `OPERATION_NOT_PERMITTED_FOR_CONTEXT` / `SETTING_TYPE_IS_NOT_COMPATIBLE_WITH_CAMPAIGN` | The field/setting only applies to a different `advertising_channel_type` (e.g. `payment_mode`, `hotel_setting` need Hotel/Local Services/Travel, not Search) | Remove the field; it wasn't meant for this campaign type |
253
+ | `OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE` | You tried to update/remove a child resource whose parent (e.g. its campaign or ad group) is already `REMOVED` | Nothing to do — it's already inert; no further action needed |
254
+ | `CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE` | The field needs vertical-specific account allowlisting (e.g. `vanity_pharma` needs pharma allowlisting) | Not fixable from the CLI side; the account needs to be approved by Google first |
255
+ | `USER_PERMISSION_DENIED` mentioning `login-customer-id` | You queried/mutated a client account directly instead of through its manager account | Pass `--login-customer-id <MCC_CID>` (global flag, works on every command) |
256
+
257
+ General rule of thumb: run the same operation with `--dry-run` first — it
258
+ triggers the exact same server-side validation as a real mutate, so every
259
+ error above shows up there before anything is actually changed.
260
+
261
+ ## Tests
262
+
263
+ ```bash
264
+ pytest
265
+ ```
266
+
267
+ All tests run without real Google Ads credentials (reflection against the
268
+ real generated protobuf classes; the RPC calls themselves are mocked with
269
+ `unittest.mock.patch.object(..., autospec=True)`).
270
+
271
+ ## Status
272
+
273
+ The core commands (`query`, `mutate`, `call`, `auth`, `accounts`, `hl`) are
274
+ implemented, tested, and live-verified against a real Google Ads account
275
+ (service account auth, cloud-managed access without a developer token):
276
+ `auth status`, `query`, `accounts list-hierarchy`, `mutate --dry-run`
277
+ (including multi-error validation) and the generic `call` fallback all work
278
+ as expected.
@@ -0,0 +1,260 @@
1
+ # google-ads-cli (`gads`)
2
+
3
+ A fully-featured command-line CLI for the Google Ads API (currently v25),
4
+ built for control by AI agents: non-interactive, with JSON as the default
5
+ input/output format and clear exit codes.
6
+
7
+ ## Why no per-resource subcommands?
8
+
9
+ The Google Ads API has 100+ services and hundreds of resources. Instead of
10
+ writing dedicated code for every single resource, `gads` exploits the API's
11
+ consistent GAPIC naming convention via reflection. Three generic commands
12
+ therefore cover **the entire API**:
13
+
14
+ - `gads query` — arbitrary GAQL queries (reporting/reading)
15
+ - `gads mutate <resource>` — generic create/update/remove for every
16
+ mutable resource (`campaign`, `ad_group`, `ad_group_criterion`, ...)
17
+ - `gads call <Service> <Method>` — fallback for everything else (BatchJobService,
18
+ ConversionUploadService, OfflineUserDataJobService, KeywordPlanService,
19
+ GoogleAdsFieldService, CustomerService, long-running operations, ...)
20
+
21
+ On top of that there's a small number of convenient high-level commands under
22
+ `gads hl` (create campaign/budget/ad group/keyword/ad) as thin wrappers —
23
+ pure ergonomics, no additional coverage needed.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install google-ads-cli
29
+ ```
30
+
31
+ For local development (editable install with test/lint dependencies):
32
+
33
+ ```bash
34
+ python3 -m venv .venv && source .venv/bin/activate
35
+ pip install -e ".[dev]"
36
+ ```
37
+
38
+ ## Setup (one-time, fully non-interactive afterwards)
39
+
40
+ Two auth methods are supported:
41
+
42
+ ### Option A: Service Account (Google Cloud Console)
43
+
44
+ Recommended when API access is managed through a GCP project/service account
45
+ (no interactive consent flow needed):
46
+
47
+ ```bash
48
+ gads auth use-service-account \
49
+ --json-key-file-path /path/to/service-account.json \
50
+ --login-customer-id <MCC_CID> # optional, if accessing via an MCC
51
+ ```
52
+
53
+ Prerequisite: the `client_email` from the key file must be added as a user on
54
+ the Google Ads account (or MCC) under **Tools & Settings > Access and
55
+ Security > Users**. A classic developer token is not strictly required here
56
+ (Google Ads "Cloud-managed access" for projects linked to a Cloud
57
+ Organization) — settable optionally via `--developer-token` if you have one.
58
+ Domain-wide delegation (impersonating a Workspace user) is only needed if the
59
+ client_email wasn't added directly as an account user; set
60
+ `--impersonated-email` for that.
61
+
62
+ The key file itself should live **outside** the repo (e.g. under
63
+ `~/.config/google-ads-cli/`) — it contains a private key and must never be
64
+ committed.
65
+
66
+ ### Option B: OAuth Installed-App Flow
67
+
68
+ ```bash
69
+ gads auth login \
70
+ --client-id <OAUTH_CLIENT_ID> \
71
+ --client-secret <OAUTH_CLIENT_SECRET> \
72
+ --developer-token <DEVELOPER_TOKEN> \
73
+ --login-customer-id <MCC_CID> # optional
74
+ ```
75
+
76
+ Both commands store the configuration under
77
+ `~/.config/google-ads-cli/google-ads.yaml` (file permissions 600). All
78
+ subsequent commands then run fully non-interactively.
79
+
80
+ For containers/CI with no file at all: every field can be overridden via env
81
+ var (`GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_CLIENT_ID`,
82
+ `GOOGLE_ADS_CLIENT_SECRET`, `GOOGLE_ADS_REFRESH_TOKEN`,
83
+ `GOOGLE_ADS_JSON_KEY_FILE_PATH`, `GOOGLE_ADS_IMPERSONATED_EMAIL`,
84
+ `GOOGLE_ADS_USE_APPLICATION_DEFAULT_CREDENTIALS`, `GOOGLE_ADS_LOGIN_CUSTOMER_ID`).
85
+
86
+ ```bash
87
+ gads auth status # checks the config against the real API
88
+ ```
89
+
90
+ ## Usage
91
+
92
+ **GAQL query (reporting):**
93
+ ```bash
94
+ gads query -c 1234567890 -q "
95
+ SELECT campaign.id, campaign.name, metrics.clicks
96
+ FROM campaign
97
+ WHERE segments.date DURING LAST_7_DAYS"
98
+ ```
99
+
100
+ **Create a campaign (generic `mutate` command):**
101
+ ```bash
102
+ gads mutate campaign -c 1234567890 -o '[
103
+ {"create": {"name": "My Campaign", "status": "PAUSED",
104
+ "advertising_channel_type": "SEARCH",
105
+ "campaign_budget": "customers/1234567890/campaignBudgets/111"}}
106
+ ]'
107
+ ```
108
+
109
+ **Validate the same operation first, without applying it:**
110
+ ```bash
111
+ gads mutate campaign -c 1234567890 --dry-run -o '[...]'
112
+ ```
113
+
114
+ **Pause a campaign (update with automatic field mask):**
115
+ ```bash
116
+ gads mutate campaign -c 1234567890 -o '[
117
+ {"update": {"resource_name": "customers/1234567890/campaigns/999", "status": "PAUSED"}}
118
+ ]'
119
+ ```
120
+
121
+ **Remove (soft-delete) a campaign:**
122
+ ```bash
123
+ gads mutate campaign -c 1234567890 -o '[
124
+ {"remove": "customers/1234567890/campaigns/999"}
125
+ ]'
126
+ ```
127
+ `remove` doesn't delete the record outright — Google Ads sets its status to
128
+ `REMOVED` and keeps it for reporting history. A removed resource can't be
129
+ mutated again afterwards (`OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE`).
130
+
131
+ ### `mutate` operation schema
132
+
133
+ `--operations-json`/`-o` takes a JSON **array**; each element is one
134
+ operation and must contain **exactly one** of these three keys:
135
+
136
+ | Key | Value | Notes |
137
+ |---|---|---|
138
+ | `create` | object of field values (snake_case, matching the resource proto) | |
139
+ | `update` | object that **must include `resource_name`** plus the fields to change | if `update_mask` is omitted, it's derived automatically from the fields you set; pass it explicitly (a list of dotted field paths) to override |
140
+ | `remove` | a bare resource name string | no nested object, just the string |
141
+
142
+ `resource_name` follows the pattern
143
+ `customers/{customer_id}/{resourcePluralCamelCase}/{id}`, e.g.
144
+ `customers/1234567890/campaigns/999`,
145
+ `customers/1234567890/adGroupCriteria/222~333` (criteria use a composite
146
+ `adGroupId~criterionId` id). You get these back as `resource_name` in the
147
+ `results` of every `create` call, so you rarely need to construct them by
148
+ hand except for `update`/`remove` on records you already know about.
149
+
150
+ **Generic fallback for any other API method:**
151
+ ```bash
152
+ gads list-services
153
+ gads list-methods BatchJobService
154
+ gads call BatchJobService mutate -c 1234567890 -r '{"customer_id": "1234567890", "mutate_operation": [...]}'
155
+ ```
156
+
157
+ **Convenience commands:**
158
+ ```bash
159
+ gads hl budget create -c 1234567890 --name "Budget A" --amount-micros 5000000
160
+ gads hl campaign create -c 1234567890 --name "Campaign A" --budget customers/1234567890/campaignBudgets/111
161
+ gads hl keyword add -c 1234567890 --ad-group customers/.../adGroups/222 --text "buy shoes" --match-type BROAD
162
+ gads accounts list-hierarchy -c <MCC_CID>
163
+ ```
164
+
165
+ **Looking up field metadata** (which fields does a resource offer? no
166
+ customer account needed — a wrapper around `GoogleAdsFieldService`, the API's
167
+ global field catalog):
168
+ ```bash
169
+ gads fields list ad_group # all fields of ad_group
170
+ gads fields list ad_group --category METRIC # metrics only
171
+ gads fields list campaign.network_settings # fields of a sub-message
172
+ gads fields show ad_group.status # full metadata incl. enum_values, selectable_with
173
+ ```
174
+ Shows `selectable`/`filterable`/`sortable`/`enum_values`/`data_type` and (for
175
+ `show`) `selectable_with` (which other resources/segments/metrics the field
176
+ can be combined with in a GAQL query). This only tells you what's *readable*
177
+ via GAQL — not what's *writable* via `mutate`, or which
178
+ `advertising_channel_type` it's compatible with. That can only be discovered
179
+ by reading the resource proto or by trying a `--dry-run` (see "Field/schema
180
+ research beyond `gads fields`" below).
181
+
182
+ `gads fields` builds a query for `GoogleAdsFieldService`'s own mini query
183
+ language internally (not full GAQL). If you ever call that service directly
184
+ via `gads call GoogleAdsFieldService search_google_ads_fields -r '{"query": "..."}'`,
185
+ note its limitations: only `AND` is supported (**no `OR`**), string literals
186
+ need double quotes, and `LIKE` uses `%` as a wildcard — the same restrictions
187
+ apply to regular GAQL queries via `gads query`.
188
+
189
+ ## Agent contract
190
+
191
+ - Every command and subcommand is self-documenting: `gads --help`,
192
+ `gads mutate --help`, `gads hl campaign create --help`, etc. always reflect
193
+ the exact, current set of flags — treat that as authoritative over any
194
+ example in this README if the two ever disagree (e.g. after an update).
195
+ - Output: plain JSON on stdout (default; `--format table`/`--format csv` for
196
+ humans). Logs/errors go to stderr.
197
+ - Exit codes: `0` success, `1` CLI/validation error, `2` Google Ads API error
198
+ (structured JSON with `error_code`, `message`, `field_path`), `70`
199
+ unexpected internal error.
200
+ - Mutate commands run immediately by default (no confirmation prompt,
201
+ important for autonomous agents); `--dry-run` uses the API's
202
+ `validate_only`.
203
+
204
+ ## Field/schema research beyond `gads fields`
205
+
206
+ `gads fields` answers "what is selectable/filterable via GAQL?". For "what
207
+ fields actually exist for `mutate`, and what type/enum value do they
208
+ expect?", a short bit of Python introspection against the locally installed,
209
+ generated classes of the `google-ads` library also helps (purely local, no
210
+ API call, no credentials needed):
211
+
212
+ ```python
213
+ from google.ads.googleads.v25.resources.types.ad_group import AdGroup
214
+ for f in AdGroup.pb().DESCRIPTOR.fields:
215
+ print(f.name, [v.name for v in f.enum_type.values] if f.enum_type else f.type)
216
+ ```
217
+
218
+ For the third question — "is this field actually writable via `mutate` for a
219
+ given `advertising_channel_type`?" — there's no metadata source for that;
220
+ only a real `--dry-run` call reveals it (see the error codes
221
+ `OPERATION_NOT_PERMITTED_FOR_CONTEXT`, `IMMUTABLE_FIELD`,
222
+ `SETTING_TYPE_IS_NOT_COMPATIBLE_WITH_CAMPAIGN` in `errors.py`).
223
+
224
+ ## Common errors and gotchas
225
+
226
+ Found through actual live testing against the API (not exhaustive, but the
227
+ ones most likely to trip up a first attempt):
228
+
229
+ | Error code | What it means | What to do |
230
+ |---|---|---|
231
+ | `DATE_RANGE_ERROR_END_TIME_MUST_BE_THE_END_OF_A_DAY` | `campaign.end_date_time` (and similar `*_date_time` fields) must literally end in `" 23:59:59"` | Use e.g. `"2026-12-31 23:59:59"`, not just the date |
232
+ | `FIELD_HAS_SUBFIELDS` | You passed an explicit `update_mask` naming a message-type field (e.g. `"vanity_pharma"`) instead of its leaf sub-fields | Omit `update_mask` for that operation and let it be derived automatically, or list the actual leaf paths (e.g. `"vanity_pharma.vanity_pharma_text"`) |
233
+ | `IMMUTABLE_FIELD` | The field can only be set on `create`, not `update` (e.g. several `app_campaign_setting.*`/`travel_campaign_settings.*` fields) — or, on `ad_group_ad`, `start_date_time`/`end_date_time` can't be set at all | Set it during `create` instead, or drop it |
234
+ | `OPERATION_NOT_PERMITTED_FOR_CONTEXT` / `SETTING_TYPE_IS_NOT_COMPATIBLE_WITH_CAMPAIGN` | The field/setting only applies to a different `advertising_channel_type` (e.g. `payment_mode`, `hotel_setting` need Hotel/Local Services/Travel, not Search) | Remove the field; it wasn't meant for this campaign type |
235
+ | `OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE` | You tried to update/remove a child resource whose parent (e.g. its campaign or ad group) is already `REMOVED` | Nothing to do — it's already inert; no further action needed |
236
+ | `CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE` | The field needs vertical-specific account allowlisting (e.g. `vanity_pharma` needs pharma allowlisting) | Not fixable from the CLI side; the account needs to be approved by Google first |
237
+ | `USER_PERMISSION_DENIED` mentioning `login-customer-id` | You queried/mutated a client account directly instead of through its manager account | Pass `--login-customer-id <MCC_CID>` (global flag, works on every command) |
238
+
239
+ General rule of thumb: run the same operation with `--dry-run` first — it
240
+ triggers the exact same server-side validation as a real mutate, so every
241
+ error above shows up there before anything is actually changed.
242
+
243
+ ## Tests
244
+
245
+ ```bash
246
+ pytest
247
+ ```
248
+
249
+ All tests run without real Google Ads credentials (reflection against the
250
+ real generated protobuf classes; the RPC calls themselves are mocked with
251
+ `unittest.mock.patch.object(..., autospec=True)`).
252
+
253
+ ## Status
254
+
255
+ The core commands (`query`, `mutate`, `call`, `auth`, `accounts`, `hl`) are
256
+ implemented, tested, and live-verified against a real Google Ads account
257
+ (service account auth, cloud-managed access without a developer token):
258
+ `auth status`, `query`, `accounts list-hierarchy`, `mutate --dry-run`
259
+ (including multi-error validation) and the generic `call` fallback all work
260
+ as expected.
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "google-ads-cli"
7
- version = "0.0.1"
8
- description = "Vollumfaengliche Kommandozeilen-CLI fuer die Google Ads API (v25), fuer die Nutzung durch KI-Agenten"
7
+ version = "0.0.2"
8
+ description = "Full-featured command-line CLI for the Google Ads API (v25), built for use by AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "Apache-2.0" }
@@ -35,8 +35,8 @@ line-length = 100
35
35
  target-version = "py310"
36
36
 
37
37
  [tool.ruff.lint]
38
- # BLE001: bewusste blinde except-Klausel als globale Fehlergrenze in _common.run_guarded.
39
- # B008: typer.Option()/typer.Argument() als Parameter-Default ist das Standard-Typer-Idiom.
38
+ # BLE001: intentional blind except clause as the global error boundary in _common.run_guarded.
39
+ # B008: typer.Option()/typer.Argument() as a parameter default is the standard Typer idiom.
40
40
  ignore = ["BLE001", "B008", "TRY004"]
41
41
 
42
42
  [tool.pytest.ini_options]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.2"
@@ -1,4 +1,4 @@
1
- """Root Typer-App: globale Optionen, Fehlerbehandlung, Subcommand-Registrierung."""
1
+ """Root Typer app: global options, error handling, subcommand registration."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -9,7 +9,7 @@ from googleadscli.commands import accounts, auth, call, fields, highlevel, mutat
9
9
  app = typer.Typer(
10
10
  no_args_is_help=True,
11
11
  add_completion=False,
12
- help="Vollumfaengliche CLI fuer die Google Ads API (v25) -- fuer KI-Agenten.",
12
+ help="Full-featured CLI for the Google Ads API (v25) -- built for AI agents.",
13
13
  )
14
14
 
15
15
 
@@ -17,10 +17,10 @@ app = typer.Typer(
17
17
  def main_callback(
18
18
  ctx: typer.Context,
19
19
  config: str = typer.Option(
20
- None, "--config", envvar="GOOGLE_ADS_CONFIGURATION_FILE_PATH", help="Pfad zur google-ads.yaml"
20
+ None, "--config", envvar="GOOGLE_ADS_CONFIGURATION_FILE_PATH", help="Path to google-ads.yaml"
21
21
  ),
22
- api_version: str = typer.Option("v25", "--api-version", help="Google Ads API Version"),
23
- output_format: str = typer.Option("json", "--format", help="Ausgabeformat: json|table|csv"),
22
+ api_version: str = typer.Option("v25", "--api-version", help="Google Ads API version"),
23
+ output_format: str = typer.Option("json", "--format", help="Output format: json|table|csv"),
24
24
  developer_token: str = typer.Option(None, "--developer-token", envvar="GOOGLE_ADS_DEVELOPER_TOKEN"),
25
25
  client_id: str = typer.Option(None, "--client-id", envvar="GOOGLE_ADS_CLIENT_ID"),
26
26
  client_secret: str = typer.Option(None, "--client-secret", envvar="GOOGLE_ADS_CLIENT_SECRET"),
@@ -29,22 +29,22 @@ def main_callback(
29
29
  None,
30
30
  "--json-key-file-path",
31
31
  envvar="GOOGLE_ADS_JSON_KEY_FILE_PATH",
32
- help="Pfad zu einer Service-Account-JSON-Schluesseldatei (Alternative zum OAuth-Flow)",
32
+ help="Path to a service-account JSON key file (alternative to the OAuth flow)",
33
33
  ),
34
34
  impersonated_email: str = typer.Option(
35
35
  None,
36
36
  "--impersonated-email",
37
37
  envvar="GOOGLE_ADS_IMPERSONATED_EMAIL",
38
- help="Nur fuer Domain-Wide-Delegation: zu impersonierender Workspace-Nutzer",
38
+ help="Only for domain-wide delegation: the Workspace user to impersonate",
39
39
  ),
40
40
  use_adc: bool = typer.Option(
41
41
  False,
42
42
  "--use-adc",
43
43
  envvar="GOOGLE_ADS_USE_APPLICATION_DEFAULT_CREDENTIALS",
44
- help="Application Default Credentials verwenden statt expliziter Zugangsdaten",
44
+ help="Use Application Default Credentials instead of explicit credentials",
45
45
  ),
46
46
  login_customer_id: str = typer.Option(None, "--login-customer-id", envvar="GOOGLE_ADS_LOGIN_CUSTOMER_ID"),
47
- debug: bool = typer.Option(False, "--debug", help="Vollstaendige Tracebacks statt strukturierter Fehler"),
47
+ debug: bool = typer.Option(False, "--debug", help="Show full tracebacks instead of structured errors"),
48
48
  ) -> None:
49
49
  ctx.obj = {
50
50
  "config_path": config,
@@ -64,10 +64,10 @@ def main_callback(
64
64
  }
65
65
 
66
66
 
67
- app.add_typer(auth.app, name="auth", help="OAuth2-Einrichtung und Zugangspruefung")
68
- app.add_typer(accounts.app, name="accounts", help="Kontohierarchie / CIDs unter einem MCC")
69
- app.add_typer(highlevel.app, name="hl", help="Komfortbefehle fuer haeufige Workflows")
70
- app.add_typer(fields.app, name="fields", help="Feld-Metadaten der API nachschlagen")
67
+ app.add_typer(auth.app, name="auth", help="OAuth2 setup and credential check")
68
+ app.add_typer(accounts.app, name="accounts", help="Account hierarchy / CIDs under an MCC")
69
+ app.add_typer(highlevel.app, name="hl", help="Convenience commands for common workflows")
70
+ app.add_typer(fields.app, name="fields", help="Look up API field metadata")
71
71
  query.register(app)
72
72
  mutate.register(app)
73
73
  call.register(app)
@@ -1,4 +1,4 @@
1
- """Aufbau des GoogleAdsClient aus der gemergten Konfiguration."""
1
+ """Builds the GoogleAdsClient from the merged configuration."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -20,5 +20,5 @@ def build_client(
20
20
  merged = load_merged_config(config_path=config_path, cli_overrides=cli_overrides)
21
21
  try:
22
22
  return GoogleAdsClient.load_from_dict(merged, version=version)
23
- except Exception as exc: # Konfigurationsfehler der Client Library selbst
24
- raise ConfigError(f"Google Ads Client konnte nicht initialisiert werden: {exc}") from exc
23
+ except Exception as exc: # configuration error from the client library itself
24
+ raise ConfigError(f"Could not initialize the Google Ads client: {exc}") from exc