adloop 0.35.0__tar.gz → 0.35.1__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 (74) hide show
  1. {adloop-0.35.0 → adloop-0.35.1}/PKG-INFO +1 -1
  2. {adloop-0.35.0 → adloop-0.35.1}/pyproject.toml +1 -1
  3. {adloop-0.35.0 → adloop-0.35.1}/pyproject.toml.orig +1 -1
  4. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/assets.py +35 -4
  5. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/write.py +133 -0
  6. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/adloop.md +4 -2
  7. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/server.py +19 -3
  8. {adloop-0.35.0 → adloop-0.35.1}/README.md +0 -0
  9. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/__init__.py +0 -0
  10. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/__main__.py +0 -0
  11. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/_mcp_patches.py +0 -0
  12. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/ai_max.py +0 -0
  14. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/audiences.py +0 -0
  15. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/shopping.py +0 -0
  27. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/snippet_headers.py +0 -0
  28. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/validate_only.py +0 -0
  29. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/auth.py +0 -0
  30. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/cli.py +0 -0
  31. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/config.py +0 -0
  32. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/crossref.py +0 -0
  33. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/diagnostics.py +0 -0
  34. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/__init__.py +0 -0
  35. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/client.py +0 -0
  36. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/reports.py +0 -0
  37. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/tracking.py +0 -0
  38. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/write.py +0 -0
  39. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/__init__.py +0 -0
  40. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/client.py +0 -0
  41. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/reports.py +0 -0
  42. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/__init__.py +0 -0
  43. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/client.py +0 -0
  44. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/entities.py +0 -0
  45. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/read.py +0 -0
  46. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/write.py +0 -0
  47. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/__init__.py +0 -0
  48. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/client.py +0 -0
  49. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/read.py +0 -0
  50. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/pagespeed.py +0 -0
  51. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/__init__.py +0 -0
  52. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/auth.py +0 -0
  53. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/client.py +0 -0
  54. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/read.py +0 -0
  55. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/schedule.py +0 -0
  56. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/write.py +0 -0
  57. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/__init__.py +0 -0
  58. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
  59. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/budget-plan.md +0 -0
  60. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/create-ad.md +0 -0
  61. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/create-campaign.md +0 -0
  62. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  63. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  64. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules_install.py +0 -0
  65. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/runtime.py +0 -0
  66. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/__init__.py +0 -0
  67. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/approval.py +0 -0
  68. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/approval_card.html +0 -0
  69. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/audit.py +0 -0
  70. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/guards.py +0 -0
  71. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/preview.py +0 -0
  72. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/tracking.py +0 -0
  73. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/upload_card.html +0 -0
  74. {adloop-0.35.0 → adloop-0.35.1}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.35.0
3
+ Version: 0.35.1
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.0"
3
+ version = "0.35.1"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.0"
3
+ version = "0.35.1"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1415,13 +1415,14 @@ def draft_promotion(
1415
1415
  scope: str = "",
1416
1416
  language_code: str = "",
1417
1417
  currency_code: str = "",
1418
+ ad_schedule: list[dict] | None = None,
1418
1419
  **promotion,
1419
1420
  ) -> dict:
1420
1421
  """Draft a promotion asset linked to an ad group, a campaign or the account.
1421
1422
 
1422
1423
  ``promotion`` takes promotion_target, final_url, money_off or percent_off,
1423
1424
  promotion_code or orders_over_amount, occasion, discount_modifier and the
1424
- four dates.
1425
+ four dates. ``ad_schedule`` limits the hours it shows.
1425
1426
  """
1426
1427
  from adloop.safety.preview import ChangePlan, store_plan
1427
1428
 
@@ -1430,6 +1431,8 @@ def draft_promotion(
1430
1431
  scope, campaign_id, ad_group_id, errors = resolve_scope(scope, campaign_id, ad_group_id)
1431
1432
  fields, parse_errors = _promotion_input(**promotion)
1432
1433
  fields["language_code"] = _language(language_code, errors)
1434
+ fields["ad_schedule"], schedule_errors = _validate_schedule(ad_schedule or [])
1435
+ errors += schedule_errors
1433
1436
  errors += parse_errors or _check_promotion(fields)
1434
1437
  if errors:
1435
1438
  return {"error": "Validation failed", "details": errors}
@@ -1456,6 +1459,8 @@ def draft_promotion(
1456
1459
  preview = plan.to_preview()
1457
1460
  preview["warnings"] = [*warnings, *_promotion_warnings(fields), *url_warnings] + (
1458
1461
  [_account_scope_warning("promotions")] if scope == "account" else [])
1462
+ if fields["ad_schedule"]:
1463
+ preview["warnings"].append(_time_zone_note(_account_settings(config, customer_id)["time_zone"]))
1459
1464
  return preview
1460
1465
 
1461
1466
 
@@ -1472,8 +1477,10 @@ _PROMOTION_FIELDS = {
1472
1477
  "discount_modifier": "asset.promotion_asset.discount_modifier",
1473
1478
  "language_code": "asset.promotion_asset.language_code",
1474
1479
  **{field: f"asset.promotion_asset.{field}" for field in _DATE_FIELDS},
1480
+ "ad_schedule": "asset.promotion_asset.ad_schedule_targets",
1475
1481
  }
1476
- _CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS)
1482
+ _CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS,
1483
+ "ad_schedule")
1477
1484
 
1478
1485
 
1479
1486
  def _promotion_from_google(raw: dict) -> dict:
@@ -1490,6 +1497,7 @@ def _promotion_from_google(raw: dict) -> dict:
1490
1497
  "discount_modifier": raw["discount_modifier"],
1491
1498
  "language_code": raw["language_code"],
1492
1499
  **{field: raw[field] for field in _DATE_FIELDS},
1500
+ "ad_schedule": _schedule_from_google(raw["ad_schedule"]),
1493
1501
  }
1494
1502
 
1495
1503
 
@@ -1501,9 +1509,14 @@ def update_promotion(
1501
1509
  language_code: str = "",
1502
1510
  currency_code: str = "",
1503
1511
  clear_fields: list[str] | None = None,
1512
+ ad_schedule: list[dict] | None = None,
1504
1513
  **promotion,
1505
1514
  ) -> dict:
1506
- """Draft an in-place edit of a promotion asset; fields not passed stay as they are."""
1515
+ """Draft an in-place edit of a promotion asset; fields not passed stay as they are.
1516
+
1517
+ A non-empty ``ad_schedule`` replaces the promotion's schedule;
1518
+ ``clear_fields=["ad_schedule"]`` removes it.
1519
+ """
1507
1520
  if blocked := _blocked("update_promotion", config):
1508
1521
  return blocked
1509
1522
  asset_id = _clean(asset_id)
@@ -1511,6 +1524,10 @@ def update_promotion(
1511
1524
  passed, parse_errors = _promotion_input(**promotion)
1512
1525
  errors += parse_errors
1513
1526
  passed = {k: v for k, v in passed.items() if v}
1527
+ windows, schedule_errors = _validate_schedule(ad_schedule or [])
1528
+ errors += schedule_errors
1529
+ if windows:
1530
+ passed["ad_schedule"] = windows
1514
1531
  if _clean(language_code):
1515
1532
  passed["language_code"] = _language(language_code, errors)
1516
1533
  clear = [_clean(f).lower() for f in clear_fields or []]
@@ -1539,7 +1556,7 @@ def update_promotion(
1539
1556
  if "orders_over_amount" in passed:
1540
1557
  merged["promotion_code"] = ""
1541
1558
  for field in clear:
1542
- merged[field] = 0.0 if field == "orders_over_amount" else ""
1559
+ merged[field] = {"orders_over_amount": 0.0, "ad_schedule": []}.get(field, "")
1543
1560
  errors = _check_promotion(merged)
1544
1561
  if errors:
1545
1562
  return {"error": "Validation failed", "details": errors}
@@ -1565,6 +1582,10 @@ def update_promotion(
1565
1582
  {"set": changed, "promotion": merged},
1566
1583
  {k: current[k] for k in changed}, pinned={k: raw[k] for k in _PROMOTION_FIELDS})
1567
1584
  preview["warnings"] += warnings + _promotion_warnings(merged)
1585
+ if "ad_schedule" in changed:
1586
+ preview["warnings"].append(
1587
+ _time_zone_note(_account_settings(config, customer_id)["time_zone"]) if merged["ad_schedule"]
1588
+ else "Without a schedule the promotion shows at all hours the campaign serves.")
1568
1589
  return preview
1569
1590
 
1570
1591
 
@@ -1589,6 +1610,10 @@ def _populate_promotion(client, asset, promo: dict) -> None:
1589
1610
  for field in _DATE_FIELDS:
1590
1611
  if promo.get(field):
1591
1612
  setattr(p, field, promo[field])
1613
+ for window in promo.get("ad_schedule") or []:
1614
+ info = client.get_type("AdScheduleInfo")
1615
+ _populate_schedule(client, info, window)
1616
+ p.ad_schedule_targets.append(info)
1592
1617
  asset.final_urls.append(promo["final_url"])
1593
1618
 
1594
1619
 
@@ -1638,6 +1663,12 @@ def _apply_update_promotion(client, cid: str, changes: dict) -> dict:
1638
1663
  if promo[field]:
1639
1664
  setattr(p, field, promo[field])
1640
1665
  paths.append(f"promotion_asset.{field}")
1666
+ if "ad_schedule" in changed: # replaces the whole list; empty clears it
1667
+ for window in promo["ad_schedule"]:
1668
+ info = client.get_type("AdScheduleInfo")
1669
+ _populate_schedule(client, info, window)
1670
+ p.ad_schedule_targets.append(info)
1671
+ paths.append("promotion_asset.ad_schedule_targets")
1641
1672
  return list(dict.fromkeys(paths))
1642
1673
 
1643
1674
  return _apply_asset_update(client, cid, changes["asset_id"], set_fields)
@@ -2267,8 +2267,11 @@ def update_campaign(
2267
2267
  )
2268
2268
  errors.extend(alias_errors)
2269
2269
 
2270
+ campaign_id = str(campaign_id or "").strip()
2270
2271
  if not campaign_id:
2271
2272
  errors.append("campaign_id is required")
2273
+ elif not campaign_id.isdigit():
2274
+ errors.append(f"campaign_id must be numeric, got '{campaign_id}'")
2272
2275
 
2273
2276
  bs = bidding_strategy.upper() if bidding_strategy else ""
2274
2277
  if bs and bs not in _VALID_BIDDING_STRATEGIES:
@@ -2361,7 +2364,46 @@ def update_campaign(
2361
2364
  'entity_type="campaign_criterion".'
2362
2365
  )
2363
2366
 
2367
+ # A budget can be shared by several campaigns, and a portfolio bid
2368
+ # strategy too: say who else a budget change reaches, and that a bidding
2369
+ # change takes the campaign out of its portfolio. Both need a double
2370
+ # confirmation, and apply re-checks the state the preview showed.
2371
+ shared: dict = {}
2372
+ requires_double_confirm = False
2373
+ if daily_budget or bs or max_cpc:
2374
+ shared = _budget_and_bidding_context(
2375
+ config, customer_id, campaign_id,
2376
+ budget=bool(daily_budget), portfolio=bool(bs or max_cpc),
2377
+ )
2378
+ if shared is None:
2379
+ return {"error": f"Campaign {campaign_id} was not found in this account"}
2380
+ if daily_budget and shared["budget_shared_with"]:
2381
+ others = ", ".join(f"'{c['name']}' ({c['id']})" for c in shared["budget_shared_with"])
2382
+ warnings.append(
2383
+ f"Shared budget: '{shared['budget_name']}' also funds {others}. The new "
2384
+ "amount is the daily budget of ALL of them together, not of this campaign "
2385
+ "alone. To change only this campaign, give it its own budget in Google Ads first."
2386
+ )
2387
+ requires_double_confirm = True
2388
+ if (bs or max_cpc) and shared.get("bidding_strategy_resource"):
2389
+ others = ", ".join(f"'{c['name']}'" for c in shared["portfolio_shared_with"]) or "no other campaigns"
2390
+ warnings.append(
2391
+ f"Portfolio bid strategy: this campaign uses '{shared['portfolio_name']}' "
2392
+ f"(also used by {others}). This change gives the campaign its own strategy "
2393
+ "and takes it out of the portfolio; the portfolio and its other campaigns stay "
2394
+ "as they are. To change the portfolio's targets for all of them, edit it in Google Ads."
2395
+ )
2396
+ requires_double_confirm = True
2397
+
2364
2398
  changes: dict = {"campaign_id": campaign_id}
2399
+ if daily_budget:
2400
+ changes["current_daily_budget"] = shared["budget_amount"]
2401
+ changes["budget_name"] = shared["budget_name"]
2402
+ # Pinned: apply refuses if the campaign's budget or its sharing changed.
2403
+ changes["budget_resource"] = shared["budget_resource"]
2404
+ changes["budget_shared_with"] = sorted(c["id"] for c in shared["budget_shared_with"])
2405
+ if bs or max_cpc:
2406
+ changes["bidding_strategy_resource"] = shared["bidding_strategy_resource"]
2365
2407
  if bs:
2366
2408
  changes["bidding_strategy"] = bs
2367
2409
  if target_cpa:
@@ -2387,6 +2429,7 @@ def update_campaign(
2387
2429
  entity_id=campaign_id,
2388
2430
  customer_id=customer_id,
2389
2431
  changes=changes,
2432
+ requires_double_confirm=requires_double_confirm,
2390
2433
  )
2391
2434
  store_plan(plan)
2392
2435
  preview = plan.to_preview()
@@ -3094,6 +3137,95 @@ def _campaign_bidding_strategy(
3094
3137
  return rows[0].get("campaign.bidding_strategy_type")
3095
3138
 
3096
3139
 
3140
+ _SHARED_RESOURCE_RE = re.compile(r"customers/\d+/(campaignBudgets|biddingStrategies)/\d+")
3141
+
3142
+
3143
+ def _campaigns_using(config: AdLoopConfig, customer_id: str, field: str, resource: str,
3144
+ campaign_id: str) -> list[dict]:
3145
+ """Other non-removed campaigns whose ``campaign.<field>`` is ``resource``."""
3146
+ from adloop.ads.gaql import execute_query
3147
+
3148
+ if not _SHARED_RESOURCE_RE.fullmatch(resource or ""):
3149
+ return []
3150
+ rows = execute_query(config, customer_id, (
3151
+ f"SELECT campaign.id, campaign.name FROM campaign WHERE campaign.{field} = '{resource}' "
3152
+ "AND campaign.status != 'REMOVED'"))
3153
+ return [{"id": str(r.get("campaign.id")), "name": r.get("campaign.name") or ""}
3154
+ for r in rows if str(r.get("campaign.id")) != str(campaign_id)]
3155
+
3156
+
3157
+ def _budget_and_bidding_context(
3158
+ config: AdLoopConfig, customer_id: str, campaign_id: str, *, budget: bool, portfolio: bool
3159
+ ) -> dict | None:
3160
+ """The campaign's budget and portfolio bid strategy, with who else uses them.
3161
+
3162
+ None when the campaign does not exist. Read failures propagate: whether a
3163
+ budget is shared is a safety question, not a decoration.
3164
+ """
3165
+ from adloop.ads.gaql import execute_query
3166
+
3167
+ rows = execute_query(config, customer_id, (
3168
+ "SELECT campaign.id, campaign.campaign_budget, campaign.bidding_strategy, "
3169
+ "campaign_budget.name, campaign_budget.amount_micros "
3170
+ f"FROM campaign WHERE campaign.id = {campaign_id}"))
3171
+ if not rows:
3172
+ return None
3173
+ row = rows[0]
3174
+ budget_rn = row.get("campaign.campaign_budget") or ""
3175
+ portfolio_rn = row.get("campaign.bidding_strategy") or ""
3176
+ context = {
3177
+ "budget_resource": budget_rn,
3178
+ "budget_name": row.get("campaign_budget.name") or "",
3179
+ "budget_amount": (row.get("campaign_budget.amount_micros") or 0) / 1_000_000,
3180
+ "budget_shared_with": (
3181
+ _campaigns_using(config, customer_id, "campaign_budget", budget_rn, campaign_id) if budget else []),
3182
+ "bidding_strategy_resource": portfolio_rn,
3183
+ "portfolio_name": "",
3184
+ "portfolio_shared_with": [],
3185
+ }
3186
+ if portfolio and portfolio_rn and _SHARED_RESOURCE_RE.fullmatch(portfolio_rn):
3187
+ names = execute_query(config, customer_id, (
3188
+ f"SELECT bidding_strategy.name FROM bidding_strategy WHERE bidding_strategy.resource_name = '{portfolio_rn}'"))
3189
+ context["portfolio_name"] = (names[0].get("bidding_strategy.name") if names else "") or portfolio_rn
3190
+ context["portfolio_shared_with"] = _campaigns_using(
3191
+ config, customer_id, "bidding_strategy", portfolio_rn, campaign_id)
3192
+ return context
3193
+
3194
+
3195
+ def _check_budget_and_bidding_unchanged(service, cid: str, changes: dict) -> None:
3196
+ """Refuse when the budget or portfolio state the preview showed has moved."""
3197
+ pins_budget = "budget_resource" in changes
3198
+ pins_bidding = "bidding_strategy_resource" in changes
3199
+ if not (pins_budget or pins_bidding):
3200
+ return # plans drafted before 0.35.1 carry no pins
3201
+ campaign_id = changes["campaign_id"]
3202
+ rows = list(service.search(customer_id=cid, query=(
3203
+ "SELECT campaign.campaign_budget, campaign.bidding_strategy "
3204
+ f"FROM campaign WHERE campaign.id = {campaign_id}")))
3205
+ if not rows:
3206
+ raise ValueError(f"Campaign {campaign_id} not found")
3207
+ campaign = rows[0].campaign
3208
+ moved = []
3209
+ if pins_budget:
3210
+ if campaign.campaign_budget != changes["budget_resource"]:
3211
+ moved.append("it uses a different budget")
3212
+ elif _SHARED_RESOURCE_RE.fullmatch(changes["budget_resource"] or ""):
3213
+ sharing = sorted(
3214
+ str(r.campaign.id) for r in service.search(customer_id=cid, query=(
3215
+ "SELECT campaign.id FROM campaign WHERE campaign.campaign_budget = "
3216
+ f"'{changes['budget_resource']}' AND campaign.status != 'REMOVED'"))
3217
+ if str(r.campaign.id) != str(campaign_id))
3218
+ if sharing != sorted(changes.get("budget_shared_with") or []):
3219
+ moved.append("other campaigns joined or left its budget")
3220
+ if pins_bidding and campaign.bidding_strategy != changes["bidding_strategy_resource"]:
3221
+ moved.append("its portfolio bid strategy changed")
3222
+ if moved:
3223
+ raise RuntimeError(
3224
+ f"Campaign {campaign_id} changed after this preview ({'; '.join(moved)}); "
3225
+ "nothing was sent. Draft the change again so the preview shows who it reaches."
3226
+ )
3227
+
3228
+
3097
3229
  def _existing_negative_geo_exclusions(
3098
3230
  config: AdLoopConfig, customer_id: str, campaign_id: str
3099
3231
  ) -> list[str]:
@@ -4264,6 +4396,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
4264
4396
 
4265
4397
  campaign_id = changes["campaign_id"]
4266
4398
  resource_name = campaign_service.campaign_path(cid, campaign_id)
4399
+ _check_budget_and_bidding_unchanged(service, cid, changes)
4267
4400
 
4268
4401
  # Bid strategy and campaign-level setting changes
4269
4402
  bs = changes.get("bidding_strategy")
@@ -294,8 +294,8 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
294
294
  | `draft_call_asset` | Create a call asset (phone number shown with the ads) for an ad group, a campaign or the account | `phone_number`, `country_code` (REQUIRED, ISO alpha-2), `campaign_id` or `ad_group_id` or `scope="account"`; optional `call_conversion_action_id`, `ad_schedule` |
295
295
  | `update_call_asset` | Edit a call asset IN PLACE: number, call conversion action / reporting state, schedule | `asset_id`; `phone_number` needs `country_code`; `ad_schedule` replaces the schedule, `clear_ad_schedule=true` removes it |
296
296
  | `add_ad_schedule` | Add ad schedule windows to a campaign; existing windows stay | `campaign_id`, `schedule` list of {day_of_week, start_hour, end_hour, start_minute/end_minute 0/15/30/45}, whole numbers; no overlaps, at most 6 a day. A campaign's FIRST schedule needs a double confirmation |
297
- | `draft_promotion` | Create a promotion asset ("20% off winter tyres") for an ad group, a campaign or the account | `promotion_target` (≤20), `final_url`, `language_code` (REQUIRED), `money_off` or `percent_off`; optional `promotion_code` (≤15) or `orders_over_amount`, `occasion`, `discount_modifier` UP_TO, dates; amounts in the account currency unless `currency_code` |
298
- | `update_promotion` | Edit a promotion IN PLACE; fields not passed stay | `asset_id` plus the fields to change; `money_off`/`percent_off` and `promotion_code`/`orders_over_amount` replace each other; `clear_fields` removes optional ones |
297
+ | `draft_promotion` | Create a promotion asset ("20% off winter tyres") for an ad group, a campaign or the account | `promotion_target` (≤20), `final_url`, `language_code` (REQUIRED), `money_off` or `percent_off`; optional `promotion_code` (≤15) or `orders_over_amount`, `occasion`, `discount_modifier` UP_TO, dates, `ad_schedule` (hours it shows); amounts in the account currency unless `currency_code` |
298
+ | `update_promotion` | Edit a promotion IN PLACE; fields not passed stay | `asset_id` plus the fields to change; `money_off`/`percent_off` and `promotion_code`/`orders_over_amount` replace each other; `ad_schedule` replaces its schedule; `clear_fields` removes optional ones (incl. `ad_schedule`) |
299
299
  | `draft_price_asset` | Create a price asset (3-8 offerings) for an ad group, a campaign or the account | `offerings` [{header ≤25, description ≤25, price, final_url, unit}], `price_type`, `language_code` (both REQUIRED); `price_qualifier` FROM/UP_TO/AVERAGE optional; account currency unless `currency_code` |
300
300
  | `draft_image_assets` | Create image assets for a campaign from local files, public image URLs or uploads (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server), `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) and/or `upload_ids` (`up_...` from `upload_images` / `list_uploads`) |
301
301
  | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
@@ -363,6 +363,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
363
363
  - `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user.
364
364
  - `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes.
365
365
  - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool.
366
+ - `update_campaign` budget changes show the current amount (`current_daily_budget`). A shared budget (`budget_shared_with` lists the other campaign IDs) or a portfolio bid strategy makes the plan require double confirmation, and apply refuses if the budget, its sharing or the portfolio changed after the preview.
366
367
  - `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
367
368
  - **Asset scope:** callouts and snippets link where the id points (`ad_group_id` → ad group, `campaign_id` → campaign). Account-wide (`scope="account"`) reaches every campaign without assets of its own; use it only when the user asks for account level. **Asset edits** (`update_callout`, `update_sitelink`, `update_structured_snippet`) change the asset everywhere it is linked; check with GAQL (`campaign_asset`, `ad_group_asset`, `customer_asset`) where else it is used before editing a shared asset. If the apply reports the asset changed in Google Ads since the preview, draft again.
368
369
  - **Call assets, schedules, promotions and prices.** Never guess `country_code` or `language_code`: take them from the number and the landing page, and ask when unclear. Money amounts are in the account currency (the preview names it); pass `currency_code` only for a different one. Ad schedule hours are in the **account time zone** (the previews show it), not the searcher's: convert the user's local hours first. A campaign without a schedule serves at all hours, so its first `add_ad_schedule` windows switch off every other hour and day: say so, the preview needs a double confirmation. To change a window, remove the old one with `remove_entity` (`entity_type="campaign_criterion"`, the `remove_id` from the preview), then add the new one. Prices must match the landing page or Google disapproves them.
@@ -526,6 +527,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
526
527
  - If changing to MANUAL_CPC: warn about the trade-offs (no automation, requires constant monitoring)
527
528
  - If removing geo or language targets: warn that this broadens targeting and may waste budget
528
529
  - If the campaign is in a learning phase: warn that changes will restart the learning phase
530
+ - **Shared budgets and portfolio strategies:** a budget change on a SHARED budget changes the daily budget of every campaign using it, and a bidding change on a campaign in a portfolio bid strategy takes it out of the portfolio. The preview names the other campaigns and asks for a double confirmation; tell the user plainly before they confirm. If they meant only this campaign, its own budget has to be set up in Google Ads first
529
531
  4. Call `update_campaign` with only the parameters that need to change
530
532
  5. Present the preview — clearly show what's changing (old → new)
531
533
  6. Wait for explicit user approval before calling `confirm_and_apply`
@@ -3337,7 +3337,11 @@ def update_campaign(
3337
3337
 
3338
3338
  Only the parameters passed are changed; omitted ones stay as they are, and at
3339
3339
  least one change is required. Replacing geo targets keeps the campaign's
3340
- negative geo exclusions (the preview lists them).
3340
+ negative geo exclusions (the preview lists them). A budget change shows the
3341
+ current amount; when the budget is shared with other campaigns, the
3342
+ preview names them and needs a double confirmation, because the new amount
3343
+ covers all of them. A bidding change on a campaign in a portfolio bid
3344
+ strategy takes it out of the portfolio, also with a double confirmation.
3341
3345
 
3342
3346
  Returns a preview with a plan_id plus any warnings; nothing changes until
3343
3347
  confirm_and_apply applies it.
@@ -3354,7 +3358,8 @@ def update_campaign(
3354
3358
  target_roas: Target ROAS as a ratio (e.g. 4.0 = 400%); required if
3355
3359
  bidding_strategy is TARGET_ROAS. 0 leaves it unchanged.
3356
3360
  daily_budget: New daily budget in the account's currency (not micros),
3357
- at most the config's max_daily_budget. 0 leaves it unchanged.
3361
+ at most the config's max_daily_budget. 0 leaves it unchanged. On a
3362
+ shared budget it is the amount for every campaign sharing it.
3358
3363
  geo_target_ids: REPLACES all positive geo targets; must not be empty
3359
3364
  when given. Common IDs: "2276" Germany, "2040" Austria, "2756"
3360
3365
  Switzerland, "2840" USA, "2826" UK.
@@ -4425,6 +4430,7 @@ def draft_promotion(
4425
4430
  end_date: str = "",
4426
4431
  redemption_start_date: str = "",
4427
4432
  redemption_end_date: str = "",
4433
+ ad_schedule: _DictList = [], # noqa: B006
4428
4434
  customer_id: str = "",
4429
4435
  ) -> dict:
4430
4436
  """Draft a promotion asset ("20% off winter tyres") — returns a PREVIEW.
@@ -4461,6 +4467,10 @@ def draft_promotion(
4461
4467
  end_date: Last day the promotion shows, YYYY-MM-DD.
4462
4468
  redemption_start_date: First day the offer can be redeemed, YYYY-MM-DD.
4463
4469
  redemption_end_date: Last day the offer can be redeemed, YYYY-MM-DD.
4470
+ ad_schedule: Optional windows when the promotion shows, each
4471
+ {day_of_week: MONDAY..SUNDAY, start_hour: 0-23, end_hour: 0-24,
4472
+ start_minute/end_minute: 0, 15, 30 or 45}. Hours are in the
4473
+ account time zone.
4464
4474
  customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
4465
4475
  the configured account.
4466
4476
  """
@@ -4486,6 +4496,7 @@ def draft_promotion(
4486
4496
  end_date=end_date,
4487
4497
  redemption_start_date=redemption_start_date,
4488
4498
  redemption_end_date=redemption_end_date,
4499
+ ad_schedule=ad_schedule,
4489
4500
  )
4490
4501
 
4491
4502
 
@@ -4508,6 +4519,7 @@ def update_promotion(
4508
4519
  redemption_start_date: str = "",
4509
4520
  redemption_end_date: str = "",
4510
4521
  clear_fields: _StrList = [], # noqa: B006
4522
+ ad_schedule: _DictList = [], # noqa: B006
4511
4523
  customer_id: str = "",
4512
4524
  ) -> dict:
4513
4525
  """Draft changing a promotion asset in place — returns a PREVIEW.
@@ -4538,7 +4550,10 @@ def update_promotion(
4538
4550
  redemption_end_date: Last day the offer can be redeemed, YYYY-MM-DD.
4539
4551
  clear_fields: Optional fields to remove: promotion_code,
4540
4552
  orders_over_amount, occasion, discount_modifier, start_date,
4541
- end_date, redemption_start_date, redemption_end_date.
4553
+ end_date, redemption_start_date, redemption_end_date,
4554
+ ad_schedule.
4555
+ ad_schedule: Windows that replace the promotion's whole schedule (same
4556
+ shape as draft_promotion). Empty leaves the schedule as it is.
4542
4557
  customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
4543
4558
  the configured account.
4544
4559
  """
@@ -4551,6 +4566,7 @@ def update_promotion(
4551
4566
  language_code=language_code,
4552
4567
  currency_code=currency_code,
4553
4568
  clear_fields=clear_fields,
4569
+ ad_schedule=ad_schedule,
4554
4570
  promotion_target=promotion_target,
4555
4571
  final_url=final_url,
4556
4572
  money_off=money_off,
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes