adloop 0.4.2__tar.gz → 0.4.3__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 (28) hide show
  1. {adloop-0.4.2 → adloop-0.4.3}/PKG-INFO +21 -12
  2. {adloop-0.4.2 → adloop-0.4.3}/README.md +20 -11
  3. {adloop-0.4.2 → adloop-0.4.3}/pyproject.toml +1 -1
  4. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/__init__.py +5 -1
  5. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/read.py +5 -0
  6. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/write.py +42 -21
  7. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/cli.py +50 -9
  8. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/server.py +2 -1
  9. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/__main__.py +0 -0
  10. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/__init__.py +0 -0
  11. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/client.py +0 -0
  12. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/currency.py +0 -0
  13. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/forecast.py +0 -0
  14. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/gaql.py +0 -0
  15. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ads/pmax.py +0 -0
  16. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/auth.py +0 -0
  17. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/bundled_credentials.json +0 -0
  18. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/config.py +0 -0
  19. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/crossref.py +0 -0
  20. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ga4/__init__.py +0 -0
  21. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ga4/client.py +0 -0
  22. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ga4/reports.py +0 -0
  23. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/ga4/tracking.py +0 -0
  24. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/safety/__init__.py +0 -0
  25. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/safety/audit.py +0 -0
  26. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/safety/guards.py +0 -0
  27. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/safety/preview.py +0 -0
  28. {adloop-0.4.2 → adloop-0.4.3}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -224,6 +224,25 @@ The wizard:
224
224
 
225
225
  - Python 3.11+
226
226
  - A Google Ads account with an MCC (Manager Account)
227
+ - A Google Ads Developer Token (see below)
228
+
229
+ ### Google Ads Developer Token
230
+
231
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
232
+
233
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
234
+ 2. In the MCC, go to **Tools & Settings → API Center**
235
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
236
+
237
+ **Access levels** — your token's access level determines what it can do:
238
+
239
+ | Level | How to Get | What It Allows |
240
+ |-------|-----------|----------------|
241
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
242
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
243
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
244
+
245
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
227
246
 
228
247
  ### Headless Servers
229
248
 
@@ -253,17 +272,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
253
272
 
254
273
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
255
274
 
256
- #### Step 3 — Google Ads Developer Token
257
-
258
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
259
- 2. In the MCC, go to **Tools & Settings → API Center**
260
- 3. Your **developer token** is shown there. Copy it.
261
-
262
- Access levels:
263
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
264
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
265
-
266
- #### Step 4 — Connect to Your Editor
275
+ #### Step 3 — Connect to Your Editor
267
276
 
268
277
  **Cursor** — Add to your project's `.cursor/mcp.json`:
269
278
 
@@ -200,6 +200,25 @@ The wizard:
200
200
 
201
201
  - Python 3.11+
202
202
  - A Google Ads account with an MCC (Manager Account)
203
+ - A Google Ads Developer Token (see below)
204
+
205
+ ### Google Ads Developer Token
206
+
207
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
208
+
209
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
210
+ 2. In the MCC, go to **Tools & Settings → API Center**
211
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
212
+
213
+ **Access levels** — your token's access level determines what it can do:
214
+
215
+ | Level | How to Get | What It Allows |
216
+ |-------|-----------|----------------|
217
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
218
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
219
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
220
+
221
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
203
222
 
204
223
  ### Headless Servers
205
224
 
@@ -229,17 +248,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
229
248
 
230
249
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
231
250
 
232
- #### Step 3 — Google Ads Developer Token
233
-
234
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
235
- 2. In the MCC, go to **Tools & Settings → API Center**
236
- 3. Your **developer token** is shown there. Copy it.
237
-
238
- Access levels:
239
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
240
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
241
-
242
- #### Step 4 — Connect to Your Editor
251
+ #### Step 3 — Connect to Your Editor
243
252
 
244
253
  **Cursor** — Add to your project's `.cursor/mcp.json`:
245
254
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.4.2"
5
+ __version__ = "0.4.3"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -11,6 +11,10 @@ def main() -> None:
11
11
  Routes to the setup wizard when called as ``adloop init``,
12
12
  otherwise starts the MCP server.
13
13
  """
14
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
15
+ print(f"adloop {__version__}")
16
+ return
17
+
14
18
  if len(sys.argv) > 1 and sys.argv[1] == "init":
15
19
  from adloop.cli import run_init_wizard
16
20
 
@@ -214,6 +214,11 @@ def get_negative_keywords(
214
214
  """
215
215
 
216
216
  rows = execute_query(config, customer_id, query)
217
+ for row in rows:
218
+ cid = row.get("campaign.id")
219
+ crit_id = row.get("campaign_criterion.criterion_id")
220
+ if cid and crit_id:
221
+ row["resource_id"] = f"{cid}~{crit_id}"
217
222
  return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
218
223
 
219
224
 
@@ -1626,6 +1626,35 @@ def _draft_status_change(
1626
1626
  # ---------------------------------------------------------------------------
1627
1627
 
1628
1628
 
1629
+ _MUTATE_RESPONSE_RESULT_FIELDS = [
1630
+ "campaign_budget_result",
1631
+ "campaign_result",
1632
+ "ad_group_result",
1633
+ "ad_group_ad_result",
1634
+ "ad_group_criterion_result",
1635
+ "campaign_criterion_result",
1636
+ "asset_result",
1637
+ "campaign_asset_result",
1638
+ "customer_asset_result",
1639
+ ]
1640
+
1641
+
1642
+ def _extract_resource_name(resp: object) -> str:
1643
+ """Extract the resource_name from a MutateOperationResponse.
1644
+
1645
+ Uses direct field access instead of WhichOneof, which doesn't work on
1646
+ proto-plus wrapped messages returned by the google-ads library.
1647
+ """
1648
+ for field in _MUTATE_RESPONSE_RESULT_FIELDS:
1649
+ try:
1650
+ result = getattr(resp, field, None)
1651
+ if result and result.resource_name:
1652
+ return result.resource_name
1653
+ except Exception:
1654
+ continue
1655
+ return ""
1656
+
1657
+
1629
1658
  def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
1630
1659
  """Dispatch to the right Google Ads mutate call based on plan.operation."""
1631
1660
  from adloop.ads.client import get_ads_client, normalize_customer_id
@@ -1818,22 +1847,20 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
1818
1847
  num_geo = len(changes.get("geo_target_ids") or [])
1819
1848
  num_lang = len(changes.get("language_ids") or [])
1820
1849
  for i, resp in enumerate(response.mutate_operation_responses):
1821
- resp_type = resp.WhichOneof("response")
1822
- if resp_type:
1823
- inner = getattr(resp, resp_type)
1824
- resource = getattr(inner, "resource_name", str(inner))
1850
+ rn = _extract_resource_name(resp)
1851
+ if rn:
1825
1852
  if i == 0:
1826
- results["campaign_budget"] = resource
1853
+ results["campaign_budget"] = rn
1827
1854
  elif i == 1:
1828
- results["campaign"] = resource
1855
+ results["campaign"] = rn
1829
1856
  elif i == 2:
1830
- results["ad_group"] = resource
1857
+ results["ad_group"] = rn
1831
1858
  elif i < 3 + num_keywords:
1832
- results.setdefault("keywords", []).append(resource)
1859
+ results.setdefault("keywords", []).append(rn)
1833
1860
  elif i < 3 + num_keywords + num_geo:
1834
- results.setdefault("geo_targets", []).append(resource)
1861
+ results.setdefault("geo_targets", []).append(rn)
1835
1862
  else:
1836
- results.setdefault("language_targets", []).append(resource)
1863
+ results.setdefault("language_targets", []).append(rn)
1837
1864
 
1838
1865
  return results
1839
1866
 
@@ -1874,14 +1901,12 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict:
1874
1901
 
1875
1902
  results: dict = {}
1876
1903
  for i, resp in enumerate(response.mutate_operation_responses):
1877
- resp_type = resp.WhichOneof("response")
1878
- if resp_type:
1879
- inner = getattr(resp, resp_type)
1880
- resource = getattr(inner, "resource_name", str(inner))
1904
+ rn = _extract_resource_name(resp)
1905
+ if rn:
1881
1906
  if i == 0:
1882
- results["ad_group"] = resource
1907
+ results["ad_group"] = rn
1883
1908
  else:
1884
- results.setdefault("keywords", []).append(resource)
1909
+ results.setdefault("keywords", []).append(rn)
1885
1910
 
1886
1911
  return results
1887
1912
 
@@ -2039,11 +2064,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
2039
2064
 
2040
2065
  results = {"updated": []}
2041
2066
  for resp in response.mutate_operation_responses:
2042
- rn = (
2043
- resp.campaign_result.resource_name
2044
- or resp.campaign_budget_result.resource_name
2045
- or resp.campaign_criterion_result.resource_name
2046
- )
2067
+ rn = _extract_resource_name(resp)
2047
2068
  if rn:
2048
2069
  results["updated"].append(rn)
2049
2070
  return results
@@ -294,16 +294,17 @@ def run_init_wizard() -> None:
294
294
  _print()
295
295
 
296
296
  existing_config = None
297
+ _original_config_backup: str | None = None
297
298
  if _CONFIG_PATH.exists():
298
299
  _print(f" Found existing config at {_CONFIG_PATH}")
299
300
  if not _prompt_bool("Overwrite existing configuration?", default=False):
300
301
  _print(" Keeping existing config. Exiting.")
301
302
  return
303
+ _original_config_backup = _CONFIG_PATH.read_text()
302
304
  try:
303
305
  import yaml
304
306
 
305
- with open(_CONFIG_PATH) as f:
306
- existing_config = yaml.safe_load(f) or {}
307
+ existing_config = yaml.safe_load(_original_config_backup) or {}
307
308
  except Exception:
308
309
  existing_config = {}
309
310
 
@@ -364,7 +365,8 @@ def run_init_wizard() -> None:
364
365
  step_num += 1
365
366
  _step_header(step_num, "Authorization & Account Discovery")
366
367
 
367
- # Write a minimal temporary config for OAuth + discovery
368
+ # Write a temporary config for OAuth + discovery. If the wizard is
369
+ # interrupted after this point, _cleanup_on_cancel restores the original.
368
370
  _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
369
371
  temp_config_yaml = _generate_config_yaml(
370
372
  project_id=project_id,
@@ -378,6 +380,40 @@ def run_init_wizard() -> None:
378
380
  )
379
381
  _CONFIG_PATH.write_text(temp_config_yaml)
380
382
 
383
+ # Everything below uses the temp config for OAuth and discovery.
384
+ # If the wizard is interrupted, restore the original config (or remove
385
+ # the temp) so we never leave a half-baked config on disk.
386
+ try:
387
+ _run_wizard_post_config(
388
+ use_bundled=use_bundled,
389
+ credentials_path=credentials_path,
390
+ project_id=project_id,
391
+ developer_token=developer_token,
392
+ login_customer_id=login_customer_id,
393
+ step_num=step_num,
394
+ _existing=_existing,
395
+ )
396
+ except KeyboardInterrupt:
397
+ if _original_config_backup is not None:
398
+ _CONFIG_PATH.write_text(_original_config_backup)
399
+ elif _CONFIG_PATH.exists():
400
+ _CONFIG_PATH.unlink()
401
+ raise
402
+
403
+
404
+ def _run_wizard_post_config(
405
+ *,
406
+ use_bundled: bool,
407
+ credentials_path: str,
408
+ project_id: str,
409
+ developer_token: str,
410
+ login_customer_id: str,
411
+ step_num: int,
412
+ _existing: object,
413
+ ) -> None:
414
+ """Run the wizard steps after the temp config has been written."""
415
+ from adloop.config import load_config
416
+
381
417
  # Optional: copy custom credentials to ~/.adloop/
382
418
  if not use_bundled and credentials_path:
383
419
  creds_expanded = Path(credentials_path).expanduser()
@@ -395,13 +431,14 @@ def run_init_wizard() -> None:
395
431
  _print()
396
432
  oauth_ok = False
397
433
  try:
398
- from adloop.config import load_config
399
434
  from adloop.auth import _oauth_flow
400
435
 
401
436
  cfg = load_config(str(_CONFIG_PATH))
402
437
  _oauth_flow(cfg)
403
438
  _print(" ✓ OAuth token saved")
404
439
  oauth_ok = True
440
+ except KeyboardInterrupt:
441
+ raise
405
442
  except Exception as exc:
406
443
  _print(f" ✗ OAuth failed: {exc}")
407
444
  _print(" You can retry later — any AdLoop tool call will trigger auth.")
@@ -427,10 +464,12 @@ def run_init_wizard() -> None:
427
464
  else:
428
465
  _print(" No GA4 properties found. Enter manually:")
429
466
  property_id = _prompt_property_id()
467
+ except KeyboardInterrupt:
468
+ raise
430
469
  except Exception as exc:
431
470
  _print(f" Could not auto-discover GA4 properties: {exc}")
432
471
  property_id = _prompt_property_id(
433
- default=_existing("ga4", "property_id"),
472
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
434
473
  )
435
474
  else:
436
475
  step_num += 1
@@ -439,7 +478,7 @@ def run_init_wizard() -> None:
439
478
  _print(" → https://analytics.google.com → Admin → Property Settings")
440
479
  _print()
441
480
  property_id = _prompt_property_id(
442
- default=_existing("ga4", "property_id"),
481
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
443
482
  )
444
483
 
445
484
  # Auto-discover Ads accounts
@@ -463,18 +502,20 @@ def run_init_wizard() -> None:
463
502
  else:
464
503
  _print(" No Ads accounts found. Enter manually:")
465
504
  customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
505
+ except KeyboardInterrupt:
506
+ raise
466
507
  except Exception as exc:
467
508
  _print(f" Could not auto-discover Ads accounts: {exc}")
468
509
  customer_id = _prompt_customer_id(
469
510
  "Ads Customer ID (XXX-XXX-XXXX)",
470
- default=_existing("ads", "customer_id"),
511
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
471
512
  )
472
513
  else:
473
514
  step_num += 1
474
515
  _step_header(step_num, "Google Ads Account")
475
516
  customer_id = _prompt_customer_id(
476
517
  "Ads Customer ID (XXX-XXX-XXXX)",
477
- default=_existing("ads", "customer_id"),
518
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
478
519
  )
479
520
 
480
521
  # Safety defaults
@@ -482,7 +523,7 @@ def run_init_wizard() -> None:
482
523
  _step_header(step_num, "Safety Defaults")
483
524
  budget_str = _prompt(
484
525
  "Max daily budget cap (safety limit)",
485
- default=str(_existing("safety", "max_daily_budget", "50")),
526
+ default=str(_existing("safety", "max_daily_budget", "50")), # type: ignore[operator]
486
527
  required=False,
487
528
  )
488
529
  try:
@@ -1039,7 +1039,8 @@ def remove_entity(
1039
1039
  "campaign_asset", "asset", or "customer_asset"
1040
1040
  entity_id: The resource ID.
1041
1041
  For keywords: "adGroupId~criterionId"
1042
- For negative_keywords: the campaign criterion ID
1042
+ For negative_keywords: "campaignId~criterionId"
1043
+ (use the resource_id field from get_negative_keywords)
1043
1044
  For campaign_asset: "campaignId~assetId~fieldType"
1044
1045
  For asset: simple asset ID
1045
1046
  For customer_asset: "assetId~fieldType"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes