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.
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/.gitignore +2 -2
- google_ads_cli-0.0.2/PKG-INFO +278 -0
- google_ads_cli-0.0.2/README.md +260 -0
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/pyproject.toml +4 -4
- google_ads_cli-0.0.2/src/googleadscli/__init__.py +1 -0
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/cli.py +13 -13
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/client_factory.py +3 -3
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/_common.py +2 -2
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/accounts.py +9 -7
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/auth.py +22 -22
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/call.py +14 -14
- google_ads_cli-0.0.2/src/googleadscli/commands/fields.py +94 -0
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/__init__.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/ad.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/ad_group.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/budget.py +3 -3
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/campaign.py +8 -8
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/highlevel/keyword.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/mutate.py +8 -8
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/commands/query.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/config.py +30 -29
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/errors.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/formatting.py +3 -3
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/gaql.py +5 -5
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/proto_bridge.py +35 -34
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/src/googleadscli/utils.py +4 -4
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/conftest.py +8 -8
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_call_command.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_config.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_errors.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_fields_command.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_formatting.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_mutate_command.py +2 -2
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_proto_bridge_call_resolution.py +1 -1
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_proto_bridge_mutate_resolution.py +2 -2
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/tests/test_query_command.py +1 -1
- google_ads_cli-0.0.1/PKG-INFO +0 -212
- google_ads_cli-0.0.1/README.md +0 -194
- google_ads_cli-0.0.1/src/googleadscli/__init__.py +0 -1
- google_ads_cli-0.0.1/src/googleadscli/commands/fields.py +0 -94
- {google_ads_cli-0.0.1 → google_ads_cli-0.0.2}/LICENSE +0 -0
- {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
|
|
15
|
-
#
|
|
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.
|
|
8
|
-
description = "
|
|
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:
|
|
39
|
-
# B008: typer.Option()/typer.Argument()
|
|
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
|
|
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="
|
|
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="
|
|
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
|
|
23
|
-
output_format: str = typer.Option("json", "--format", help="
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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
|
|
68
|
-
app.add_typer(accounts.app, name="accounts", help="
|
|
69
|
-
app.add_typer(highlevel.app, name="hl", help="
|
|
70
|
-
app.add_typer(fields.app, name="fields", help="
|
|
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
|
-
"""
|
|
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: #
|
|
24
|
-
raise ConfigError(f"
|
|
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
|