adloop 0.1.0__py3-none-any.whl

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.
adloop/ads/write.py ADDED
@@ -0,0 +1,950 @@
1
+ """Google Ads write tools — all behind the safety layer.
2
+
3
+ Every write tool returns a preview/plan. Nothing executes until
4
+ ``confirm_and_apply`` is called with the plan ID.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from adloop.config import AdLoopConfig
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Draft tools — validate inputs, create a ChangePlan, return preview
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def draft_responsive_search_ad(
21
+ config: AdLoopConfig,
22
+ *,
23
+ customer_id: str = "",
24
+ ad_group_id: str = "",
25
+ headlines: list[str] | None = None,
26
+ descriptions: list[str] | None = None,
27
+ final_url: str = "",
28
+ path1: str = "",
29
+ path2: str = "",
30
+ ) -> dict:
31
+ """Draft a Responsive Search Ad — returns preview, does NOT execute."""
32
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
33
+ from adloop.safety.preview import ChangePlan, store_plan
34
+
35
+ try:
36
+ check_blocked_operation("create_responsive_search_ad", config.safety)
37
+ except SafetyViolation as e:
38
+ return {"error": str(e)}
39
+
40
+ headlines = headlines or []
41
+ descriptions = descriptions or []
42
+
43
+ errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url)
44
+ if errors:
45
+ return {"error": "Validation failed", "details": errors}
46
+
47
+ warnings = []
48
+ if len(headlines) < 8:
49
+ warnings.append(
50
+ f"Only {len(headlines)} headlines provided. Google recommends 8-15 "
51
+ "diverse headlines for optimal RSA performance."
52
+ )
53
+ if len(descriptions) < 3:
54
+ warnings.append(
55
+ f"Only {len(descriptions)} descriptions provided. Google recommends "
56
+ "3-4 descriptions for optimal RSA performance."
57
+ )
58
+
59
+ plan = ChangePlan(
60
+ operation="create_responsive_search_ad",
61
+ entity_type="ad",
62
+ customer_id=customer_id,
63
+ changes={
64
+ "ad_group_id": ad_group_id,
65
+ "headlines": headlines,
66
+ "descriptions": descriptions,
67
+ "final_url": final_url,
68
+ "path1": path1,
69
+ "path2": path2,
70
+ },
71
+ )
72
+ store_plan(plan)
73
+ preview = plan.to_preview()
74
+ if warnings:
75
+ preview["warnings"] = warnings
76
+ return preview
77
+
78
+
79
+ def draft_keywords(
80
+ config: AdLoopConfig,
81
+ *,
82
+ customer_id: str = "",
83
+ ad_group_id: str = "",
84
+ keywords: list[dict] | None = None,
85
+ ) -> dict:
86
+ """Draft keyword additions with match types — returns preview."""
87
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
88
+ from adloop.safety.preview import ChangePlan, store_plan
89
+
90
+ try:
91
+ check_blocked_operation("add_keywords", config.safety)
92
+ except SafetyViolation as e:
93
+ return {"error": str(e)}
94
+
95
+ keywords = keywords or []
96
+
97
+ errors = _validate_keywords(ad_group_id, keywords)
98
+ if errors:
99
+ return {"error": "Validation failed", "details": errors}
100
+
101
+ warnings = _check_broad_match_safety(config, customer_id, ad_group_id, keywords)
102
+
103
+ plan = ChangePlan(
104
+ operation="add_keywords",
105
+ entity_type="keyword",
106
+ customer_id=customer_id,
107
+ changes={
108
+ "ad_group_id": ad_group_id,
109
+ "keywords": keywords,
110
+ },
111
+ )
112
+ store_plan(plan)
113
+ preview = plan.to_preview()
114
+ if warnings:
115
+ preview["warnings"] = warnings
116
+ return preview
117
+
118
+
119
+ def add_negative_keywords(
120
+ config: AdLoopConfig,
121
+ *,
122
+ customer_id: str = "",
123
+ campaign_id: str = "",
124
+ keywords: list[str] | None = None,
125
+ match_type: str = "EXACT",
126
+ ) -> dict:
127
+ """Draft negative keyword additions — returns preview."""
128
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
129
+ from adloop.safety.preview import ChangePlan, store_plan
130
+
131
+ try:
132
+ check_blocked_operation("add_negative_keywords", config.safety)
133
+ except SafetyViolation as e:
134
+ return {"error": str(e)}
135
+
136
+ keywords = keywords or []
137
+ match_type = match_type.upper()
138
+
139
+ errors = []
140
+ if not campaign_id:
141
+ errors.append("campaign_id is required")
142
+ if not keywords:
143
+ errors.append("At least one keyword is required")
144
+ if match_type not in _VALID_MATCH_TYPES:
145
+ errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
146
+ if errors:
147
+ return {"error": "Validation failed", "details": errors}
148
+
149
+ plan = ChangePlan(
150
+ operation="add_negative_keywords",
151
+ entity_type="negative_keyword",
152
+ entity_id=campaign_id,
153
+ customer_id=customer_id,
154
+ changes={
155
+ "campaign_id": campaign_id,
156
+ "keywords": keywords,
157
+ "match_type": match_type,
158
+ },
159
+ )
160
+ store_plan(plan)
161
+ return plan.to_preview()
162
+
163
+
164
+ def pause_entity(
165
+ config: AdLoopConfig,
166
+ *,
167
+ customer_id: str = "",
168
+ entity_type: str = "",
169
+ entity_id: str = "",
170
+ ) -> dict:
171
+ """Draft pausing a campaign/ad group/ad/keyword — returns preview."""
172
+ return _draft_status_change(
173
+ config, "pause_entity", customer_id, entity_type, entity_id, "PAUSED"
174
+ )
175
+
176
+
177
+ def enable_entity(
178
+ config: AdLoopConfig,
179
+ *,
180
+ customer_id: str = "",
181
+ entity_type: str = "",
182
+ entity_id: str = "",
183
+ ) -> dict:
184
+ """Draft enabling a paused entity — returns preview."""
185
+ return _draft_status_change(
186
+ config, "enable_entity", customer_id, entity_type, entity_id, "ENABLED"
187
+ )
188
+
189
+
190
+ def remove_entity(
191
+ config: AdLoopConfig,
192
+ *,
193
+ customer_id: str = "",
194
+ entity_type: str = "",
195
+ entity_id: str = "",
196
+ ) -> dict:
197
+ """Draft removing an entity (keyword, negative_keyword, ad, ad_group, campaign).
198
+
199
+ This is a DESTRUCTIVE operation — removed entities cannot be re-enabled.
200
+ For keywords and negative keywords, this fully deletes the criterion.
201
+ Returns a preview; call confirm_and_apply to execute.
202
+ """
203
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
204
+ from adloop.safety.preview import ChangePlan, store_plan
205
+
206
+ try:
207
+ check_blocked_operation("remove_entity", config.safety)
208
+ except SafetyViolation as e:
209
+ return {"error": str(e)}
210
+
211
+ errors = []
212
+ if entity_type not in _REMOVABLE_ENTITY_TYPES:
213
+ errors.append(
214
+ f"entity_type must be one of {_REMOVABLE_ENTITY_TYPES}, "
215
+ f"got '{entity_type}'"
216
+ )
217
+ if not entity_id:
218
+ errors.append("entity_id is required")
219
+ if errors:
220
+ return {"error": "Validation failed", "details": errors}
221
+
222
+ plan = ChangePlan(
223
+ operation="remove_entity",
224
+ entity_type=entity_type,
225
+ entity_id=entity_id,
226
+ customer_id=customer_id,
227
+ changes={"action": "REMOVE"},
228
+ )
229
+ store_plan(plan)
230
+ return plan.to_preview()
231
+
232
+
233
+ def draft_campaign(
234
+ config: AdLoopConfig,
235
+ *,
236
+ customer_id: str = "",
237
+ campaign_name: str = "",
238
+ daily_budget: float = 0,
239
+ bidding_strategy: str = "",
240
+ target_cpa: float = 0,
241
+ target_roas: float = 0,
242
+ channel_type: str = "SEARCH",
243
+ ad_group_name: str = "",
244
+ keywords: list[dict] | None = None,
245
+ ) -> dict:
246
+ """Draft a full campaign structure — returns preview, does NOT execute.
247
+
248
+ Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords.
249
+ Ads are NOT included — use draft_responsive_search_ad separately after the
250
+ campaign exists.
251
+ """
252
+ from adloop.safety.guards import (
253
+ SafetyViolation,
254
+ check_blocked_operation,
255
+ check_budget_cap,
256
+ )
257
+ from adloop.safety.preview import ChangePlan, store_plan
258
+
259
+ try:
260
+ check_blocked_operation("create_campaign", config.safety)
261
+ except SafetyViolation as e:
262
+ return {"error": str(e)}
263
+
264
+ errors, warnings = _validate_campaign(
265
+ config,
266
+ campaign_name=campaign_name,
267
+ daily_budget=daily_budget,
268
+ bidding_strategy=bidding_strategy,
269
+ target_cpa=target_cpa,
270
+ target_roas=target_roas,
271
+ channel_type=channel_type,
272
+ keywords=keywords,
273
+ )
274
+ if errors:
275
+ return {"error": "Validation failed", "details": errors}
276
+
277
+ try:
278
+ check_budget_cap(daily_budget, config.safety)
279
+ except SafetyViolation as e:
280
+ return {"error": str(e)}
281
+
282
+ plan = ChangePlan(
283
+ operation="create_campaign",
284
+ entity_type="campaign",
285
+ customer_id=customer_id,
286
+ changes={
287
+ "campaign_name": campaign_name,
288
+ "daily_budget": daily_budget,
289
+ "bidding_strategy": bidding_strategy.upper(),
290
+ "target_cpa": target_cpa if target_cpa else None,
291
+ "target_roas": target_roas if target_roas else None,
292
+ "channel_type": channel_type.upper(),
293
+ "ad_group_name": ad_group_name or campaign_name,
294
+ "keywords": keywords,
295
+ },
296
+ )
297
+ store_plan(plan)
298
+ preview = plan.to_preview()
299
+ if warnings:
300
+ preview["warnings"] = warnings
301
+ return preview
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # confirm_and_apply — the only function that actually mutates Google Ads
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def confirm_and_apply(
310
+ config: AdLoopConfig,
311
+ *,
312
+ plan_id: str = "",
313
+ dry_run: bool = True,
314
+ ) -> dict:
315
+ """Execute a previously previewed change.
316
+
317
+ Defaults to dry_run=True. The caller must explicitly pass dry_run=False
318
+ to make real changes.
319
+ """
320
+ from adloop.safety.audit import log_mutation
321
+ from adloop.safety.preview import get_plan, remove_plan
322
+
323
+ plan = get_plan(plan_id)
324
+ if plan is None:
325
+ return {
326
+ "error": f"No pending plan found with id '{plan_id}'. "
327
+ "Plans expire when the MCP server restarts.",
328
+ }
329
+
330
+ if config.safety.require_dry_run:
331
+ dry_run = True
332
+
333
+ if dry_run:
334
+ log_mutation(
335
+ config.safety.log_file,
336
+ operation=plan.operation,
337
+ customer_id=plan.customer_id,
338
+ entity_type=plan.entity_type,
339
+ entity_id=plan.entity_id,
340
+ changes=plan.changes,
341
+ dry_run=True,
342
+ result="dry_run_success",
343
+ )
344
+ return {
345
+ "status": "DRY_RUN_SUCCESS",
346
+ "plan_id": plan.plan_id,
347
+ "operation": plan.operation,
348
+ "changes": plan.changes,
349
+ "message": (
350
+ "Dry run completed — no changes were made to your Google Ads account. "
351
+ "To apply for real, call confirm_and_apply again with dry_run=false."
352
+ ),
353
+ }
354
+
355
+ try:
356
+ result = _execute_plan(config, plan)
357
+ except Exception as e:
358
+ log_mutation(
359
+ config.safety.log_file,
360
+ operation=plan.operation,
361
+ customer_id=plan.customer_id,
362
+ entity_type=plan.entity_type,
363
+ entity_id=plan.entity_id,
364
+ changes=plan.changes,
365
+ dry_run=False,
366
+ result="error",
367
+ error=str(e),
368
+ )
369
+ return {"error": str(e), "plan_id": plan.plan_id}
370
+
371
+ log_mutation(
372
+ config.safety.log_file,
373
+ operation=plan.operation,
374
+ customer_id=plan.customer_id,
375
+ entity_type=plan.entity_type,
376
+ entity_id=plan.entity_id,
377
+ changes=plan.changes,
378
+ dry_run=False,
379
+ result="success",
380
+ )
381
+ remove_plan(plan.plan_id)
382
+
383
+ return {
384
+ "status": "APPLIED",
385
+ "plan_id": plan.plan_id,
386
+ "operation": plan.operation,
387
+ "result": result,
388
+ }
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Internal validation helpers
393
+ # ---------------------------------------------------------------------------
394
+
395
+ _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
396
+ _VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"}
397
+ _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {"negative_keyword"}
398
+
399
+ _SMART_BIDDING_STRATEGIES = {
400
+ "MAXIMIZE_CONVERSIONS",
401
+ "MAXIMIZE_CONVERSION_VALUE",
402
+ "TARGET_CPA",
403
+ "TARGET_ROAS",
404
+ }
405
+
406
+
407
+ def _check_broad_match_safety(
408
+ config: AdLoopConfig,
409
+ customer_id: str,
410
+ ad_group_id: str,
411
+ keywords: list[dict],
412
+ ) -> list[str]:
413
+ """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign."""
414
+ has_broad = any(
415
+ kw.get("match_type", "").upper() == "BROAD" for kw in keywords
416
+ )
417
+ if not has_broad:
418
+ return []
419
+
420
+ try:
421
+ from adloop.ads.gaql import execute_query
422
+
423
+ query = f"""
424
+ SELECT campaign.bidding_strategy_type, campaign.name
425
+ FROM ad_group
426
+ WHERE ad_group.id = {ad_group_id}
427
+ """
428
+ rows = execute_query(config, customer_id, query)
429
+ if not rows:
430
+ return []
431
+
432
+ bidding = rows[0].get("campaign.bidding_strategy_type", "")
433
+ campaign_name = rows[0].get("campaign.name", "")
434
+
435
+ if bidding not in _SMART_BIDDING_STRATEGIES:
436
+ return [
437
+ f"DANGEROUS: Adding BROAD match keywords to campaign "
438
+ f"'{campaign_name}' which uses {bidding} bidding. "
439
+ f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) "
440
+ f"leads to irrelevant matches and wasted budget. "
441
+ f"Use PHRASE or EXACT match instead, or switch the campaign "
442
+ f"to Smart Bidding first."
443
+ ]
444
+ except Exception:
445
+ pass
446
+
447
+ return []
448
+
449
+
450
+ def _validate_rsa(
451
+ ad_group_id: str,
452
+ headlines: list[str],
453
+ descriptions: list[str],
454
+ final_url: str,
455
+ ) -> list[str]:
456
+ errors = []
457
+ if not ad_group_id:
458
+ errors.append("ad_group_id is required")
459
+ if not final_url:
460
+ errors.append("final_url is required")
461
+ if len(headlines) < 3:
462
+ errors.append(f"Need at least 3 headlines, got {len(headlines)}")
463
+ if len(headlines) > 15:
464
+ errors.append(f"Maximum 15 headlines, got {len(headlines)}")
465
+ if len(descriptions) < 2:
466
+ errors.append(f"Need at least 2 descriptions, got {len(descriptions)}")
467
+ if len(descriptions) > 4:
468
+ errors.append(f"Maximum 4 descriptions, got {len(descriptions)}")
469
+ for i, h in enumerate(headlines):
470
+ if len(h) > 30:
471
+ errors.append(f"Headline {i + 1} exceeds 30 chars ({len(h)}): '{h}'")
472
+ for i, d in enumerate(descriptions):
473
+ if len(d) > 90:
474
+ errors.append(f"Description {i + 1} exceeds 90 chars ({len(d)}): '{d}'")
475
+ return errors
476
+
477
+
478
+ _VALID_BIDDING_STRATEGIES = {
479
+ "MAXIMIZE_CONVERSIONS",
480
+ "MAXIMIZE_CONVERSION_VALUE",
481
+ "TARGET_CPA",
482
+ "TARGET_ROAS",
483
+ "TARGET_SPEND",
484
+ "MANUAL_CPC",
485
+ }
486
+
487
+ _VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"}
488
+
489
+
490
+ def _validate_campaign(
491
+ config: AdLoopConfig,
492
+ *,
493
+ campaign_name: str,
494
+ daily_budget: float,
495
+ bidding_strategy: str,
496
+ target_cpa: float,
497
+ target_roas: float,
498
+ channel_type: str,
499
+ keywords: list[dict] | None,
500
+ ) -> tuple[list[str], list[str]]:
501
+ """Validate campaign draft inputs. Returns (errors, warnings)."""
502
+ errors = []
503
+ warnings = []
504
+
505
+ if not campaign_name or not campaign_name.strip():
506
+ errors.append("campaign_name is required")
507
+ if daily_budget <= 0:
508
+ errors.append("daily_budget must be greater than 0")
509
+
510
+ bs = bidding_strategy.upper()
511
+ if bs not in _VALID_BIDDING_STRATEGIES:
512
+ errors.append(
513
+ f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, "
514
+ f"got '{bidding_strategy}'"
515
+ )
516
+ if bs == "TARGET_CPA" and not target_cpa:
517
+ errors.append("target_cpa is required when bidding_strategy is TARGET_CPA")
518
+ if bs == "TARGET_ROAS" and not target_roas:
519
+ errors.append("target_roas is required when bidding_strategy is TARGET_ROAS")
520
+
521
+ ct = channel_type.upper()
522
+ if ct not in _VALID_CHANNEL_TYPES:
523
+ errors.append(
524
+ f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, "
525
+ f"got '{channel_type}'"
526
+ )
527
+
528
+ if keywords:
529
+ has_broad = any(
530
+ kw.get("match_type", "").upper() == "BROAD" for kw in keywords
531
+ )
532
+ if has_broad and bs not in _SMART_BIDDING_STRATEGIES:
533
+ errors.append(
534
+ f"BROAD match keywords require Smart Bidding "
535
+ f"(tCPA/tROAS/Maximize Conversions). "
536
+ f"'{bidding_strategy}' is not a Smart Bidding strategy. "
537
+ f"Use PHRASE or EXACT match instead."
538
+ )
539
+ for i, kw in enumerate(keywords):
540
+ if not kw.get("text"):
541
+ errors.append(f"Keyword {i + 1} has no text")
542
+ mt = kw.get("match_type", "").upper()
543
+ if mt not in _VALID_MATCH_TYPES:
544
+ errors.append(
545
+ f"Keyword {i + 1} has invalid match_type '{mt}' "
546
+ "(must be EXACT, PHRASE, or BROAD)"
547
+ )
548
+
549
+ if target_cpa > 0 and daily_budget < 5 * target_cpa:
550
+ warnings.append(
551
+ f"Daily budget €{daily_budget:.2f} is less than 5x target CPA "
552
+ f"€{target_cpa:.2f}. Google recommends at least 5x target CPA "
553
+ f"(€{5 * target_cpa:.2f}/day) for sufficient learning data."
554
+ )
555
+
556
+ if bs == "MANUAL_CPC":
557
+ warnings.append(
558
+ "MANUAL_CPC bidding requires constant monitoring. Consider using "
559
+ "MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization."
560
+ )
561
+
562
+ return errors, warnings
563
+
564
+
565
+ def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]:
566
+ errors = []
567
+ if not ad_group_id:
568
+ errors.append("ad_group_id is required")
569
+ if not keywords:
570
+ errors.append("At least one keyword is required")
571
+ for i, kw in enumerate(keywords):
572
+ if not kw.get("text"):
573
+ errors.append(f"Keyword {i + 1} has no text")
574
+ mt = kw.get("match_type", "").upper()
575
+ if mt not in _VALID_MATCH_TYPES:
576
+ errors.append(
577
+ f"Keyword {i + 1} has invalid match_type '{mt}' "
578
+ "(must be EXACT, PHRASE, or BROAD)"
579
+ )
580
+ return errors
581
+
582
+
583
+ def _draft_status_change(
584
+ config: AdLoopConfig,
585
+ operation: str,
586
+ customer_id: str,
587
+ entity_type: str,
588
+ entity_id: str,
589
+ target_status: str,
590
+ ) -> dict:
591
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
592
+ from adloop.safety.preview import ChangePlan, store_plan
593
+
594
+ try:
595
+ check_blocked_operation(operation, config.safety)
596
+ except SafetyViolation as e:
597
+ return {"error": str(e)}
598
+
599
+ errors = []
600
+ if entity_type not in _VALID_ENTITY_TYPES:
601
+ errors.append(
602
+ f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'"
603
+ )
604
+ if not entity_id:
605
+ errors.append("entity_id is required")
606
+ if errors:
607
+ return {"error": "Validation failed", "details": errors}
608
+
609
+ plan = ChangePlan(
610
+ operation=operation,
611
+ entity_type=entity_type,
612
+ entity_id=entity_id,
613
+ customer_id=customer_id,
614
+ changes={"target_status": target_status},
615
+ )
616
+ store_plan(plan)
617
+ return plan.to_preview()
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # Execution — actual Google Ads API mutate calls
622
+ # ---------------------------------------------------------------------------
623
+
624
+
625
+ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
626
+ """Dispatch to the right Google Ads mutate call based on plan.operation."""
627
+ from adloop.ads.client import get_ads_client, normalize_customer_id
628
+
629
+ client = get_ads_client(config)
630
+ cid = normalize_customer_id(plan.customer_id)
631
+
632
+ dispatch = {
633
+ "create_campaign": _apply_create_campaign,
634
+ "create_responsive_search_ad": _apply_create_rsa,
635
+ "add_keywords": _apply_add_keywords,
636
+ "add_negative_keywords": _apply_add_negative_keywords,
637
+ "pause_entity": _apply_status_change,
638
+ "enable_entity": _apply_status_change,
639
+ "remove_entity": _apply_remove,
640
+ }
641
+
642
+ handler = dispatch.get(plan.operation)
643
+ if handler is None:
644
+ raise ValueError(f"Unknown operation: {plan.operation}")
645
+
646
+ if plan.operation in ("pause_entity", "enable_entity"):
647
+ return handler(
648
+ client,
649
+ cid,
650
+ plan.entity_type,
651
+ plan.entity_id,
652
+ plan.changes["target_status"],
653
+ )
654
+
655
+ if plan.operation == "remove_entity":
656
+ return handler(client, cid, plan.entity_type, plan.entity_id)
657
+
658
+ return handler(client, cid, plan.changes)
659
+
660
+
661
+ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
662
+ """Create campaign + budget + ad group + optional keywords atomically."""
663
+ service = client.get_service("GoogleAdsService")
664
+ campaign_service = client.get_service("CampaignService")
665
+ budget_service = client.get_service("CampaignBudgetService")
666
+ ad_group_service = client.get_service("AdGroupService")
667
+
668
+ operations = []
669
+
670
+ # 1. CampaignBudget (temp ID: -1)
671
+ budget_op = client.get_type("MutateOperation")
672
+ budget = budget_op.campaign_budget_operation.create
673
+ budget.resource_name = budget_service.campaign_budget_path(cid, "-1")
674
+ budget.name = f"Budget - {changes['campaign_name']}"
675
+ budget.amount_micros = int(changes["daily_budget"] * 1_000_000)
676
+ budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD
677
+ budget.explicitly_shared = False
678
+ operations.append(budget_op)
679
+
680
+ # 2. Campaign (temp ID: -2, references budget -1)
681
+ campaign_op = client.get_type("MutateOperation")
682
+ campaign = campaign_op.campaign_operation.create
683
+ campaign.resource_name = campaign_service.campaign_path(cid, "-2")
684
+ campaign.name = changes["campaign_name"]
685
+ campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1")
686
+ campaign.status = client.enums.CampaignStatusEnum.PAUSED
687
+
688
+ channel = changes.get("channel_type", "SEARCH")
689
+ campaign.advertising_channel_type = getattr(
690
+ client.enums.AdvertisingChannelTypeEnum, channel
691
+ )
692
+
693
+ bs = changes["bidding_strategy"]
694
+ if bs == "MAXIMIZE_CONVERSIONS":
695
+ campaign.maximize_conversions.target_cpa_micros = 0
696
+ if changes.get("target_cpa"):
697
+ campaign.maximize_conversions.target_cpa_micros = int(
698
+ changes["target_cpa"] * 1_000_000
699
+ )
700
+ elif bs == "TARGET_CPA":
701
+ campaign.maximize_conversions.target_cpa_micros = int(
702
+ changes["target_cpa"] * 1_000_000
703
+ )
704
+ elif bs == "MAXIMIZE_CONVERSION_VALUE":
705
+ campaign.maximize_conversion_value.target_roas = 0
706
+ if changes.get("target_roas"):
707
+ campaign.maximize_conversion_value.target_roas = changes["target_roas"]
708
+ elif bs == "TARGET_ROAS":
709
+ campaign.maximize_conversion_value.target_roas = changes["target_roas"]
710
+ elif bs == "TARGET_SPEND":
711
+ campaign.target_spend.target_spend_micros = 0
712
+ elif bs == "MANUAL_CPC":
713
+ campaign.manual_cpc.enhanced_cpc_enabled = False
714
+
715
+ campaign.network_settings.target_google_search = True
716
+ campaign.network_settings.target_search_network = False
717
+ campaign.network_settings.target_content_network = False
718
+
719
+ operations.append(campaign_op)
720
+
721
+ # 3. AdGroup (temp ID: -3, references campaign -2)
722
+ ag_op = client.get_type("MutateOperation")
723
+ ad_group = ag_op.ad_group_operation.create
724
+ ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3")
725
+ ad_group.name = changes.get("ad_group_name", changes["campaign_name"])
726
+ ad_group.campaign = campaign_service.campaign_path(cid, "-2")
727
+ ad_group.status = client.enums.AdGroupStatusEnum.ENABLED
728
+ ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD
729
+ operations.append(ag_op)
730
+
731
+ # 4. Keywords (reference ad_group -3)
732
+ kw_list = changes.get("keywords") or []
733
+ for kw in kw_list:
734
+ kw_op = client.get_type("MutateOperation")
735
+ criterion = kw_op.ad_group_criterion_operation.create
736
+ criterion.ad_group = ad_group_service.ad_group_path(cid, "-3")
737
+ criterion.keyword.text = kw["text"]
738
+ criterion.keyword.match_type = getattr(
739
+ client.enums.KeywordMatchTypeEnum, kw["match_type"].upper()
740
+ )
741
+ operations.append(kw_op)
742
+
743
+ response = service.mutate(customer_id=cid, mutate_operations=operations)
744
+
745
+ resource_names = [r.mutate_operation_response for r in response.mutate_operation_responses]
746
+ results = {}
747
+ for i, resp in enumerate(response.mutate_operation_responses):
748
+ resp_type = resp.WhichOneof("response")
749
+ if resp_type:
750
+ inner = getattr(resp, resp_type)
751
+ resource = getattr(inner, "resource_name", str(inner))
752
+ if i == 0:
753
+ results["campaign_budget"] = resource
754
+ elif i == 1:
755
+ results["campaign"] = resource
756
+ elif i == 2:
757
+ results["ad_group"] = resource
758
+ else:
759
+ results.setdefault("keywords", []).append(resource)
760
+
761
+ return results
762
+
763
+
764
+ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict:
765
+ service = client.get_service("AdGroupAdService")
766
+ operation = client.get_type("AdGroupAdOperation")
767
+ ad_group_ad = operation.create
768
+
769
+ ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path(
770
+ cid, changes["ad_group_id"]
771
+ )
772
+ # Create as PAUSED for safety — user can enable separately
773
+ ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED
774
+
775
+ ad = ad_group_ad.ad
776
+ ad.final_urls.append(changes["final_url"])
777
+
778
+ for text in changes["headlines"]:
779
+ asset = client.get_type("AdTextAsset")
780
+ asset.text = text
781
+ ad.responsive_search_ad.headlines.append(asset)
782
+
783
+ for text in changes["descriptions"]:
784
+ asset = client.get_type("AdTextAsset")
785
+ asset.text = text
786
+ ad.responsive_search_ad.descriptions.append(asset)
787
+
788
+ if changes.get("path1"):
789
+ ad.responsive_search_ad.path1 = changes["path1"]
790
+ if changes.get("path2"):
791
+ ad.responsive_search_ad.path2 = changes["path2"]
792
+
793
+ response = service.mutate_ad_group_ads(
794
+ customer_id=cid, operations=[operation]
795
+ )
796
+ return {"resource_name": response.results[0].resource_name}
797
+
798
+
799
+ def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict:
800
+ service = client.get_service("AdGroupCriterionService")
801
+ ad_group_path = client.get_service("AdGroupService").ad_group_path(
802
+ cid, changes["ad_group_id"]
803
+ )
804
+
805
+ operations = []
806
+ for kw in changes["keywords"]:
807
+ operation = client.get_type("AdGroupCriterionOperation")
808
+ criterion = operation.create
809
+ criterion.ad_group = ad_group_path
810
+ criterion.keyword.text = kw["text"]
811
+ criterion.keyword.match_type = getattr(
812
+ client.enums.KeywordMatchTypeEnum, kw["match_type"].upper()
813
+ )
814
+ operations.append(operation)
815
+
816
+ response = service.mutate_ad_group_criteria(
817
+ customer_id=cid, operations=operations
818
+ )
819
+ return {"resource_names": [r.resource_name for r in response.results]}
820
+
821
+
822
+ def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict:
823
+ service = client.get_service("CampaignCriterionService")
824
+ campaign_path = client.get_service("CampaignService").campaign_path(
825
+ cid, changes["campaign_id"]
826
+ )
827
+
828
+ operations = []
829
+ for kw_text in changes["keywords"]:
830
+ operation = client.get_type("CampaignCriterionOperation")
831
+ criterion = operation.create
832
+ criterion.campaign = campaign_path
833
+ criterion.negative = True
834
+ criterion.keyword.text = kw_text
835
+ criterion.keyword.match_type = getattr(
836
+ client.enums.KeywordMatchTypeEnum, changes["match_type"]
837
+ )
838
+ operations.append(operation)
839
+
840
+ response = service.mutate_campaign_criteria(
841
+ customer_id=cid, operations=operations
842
+ )
843
+ return {"resource_names": [r.resource_name for r in response.results]}
844
+
845
+
846
+ def _apply_remove(
847
+ client: object,
848
+ cid: str,
849
+ entity_type: str,
850
+ entity_id: str,
851
+ ) -> dict:
852
+ """Remove an entity via the REMOVE mutate operation (irreversible)."""
853
+ if entity_type == "campaign":
854
+ service = client.get_service("CampaignService")
855
+ operation = client.get_type("CampaignOperation")
856
+ operation.remove = service.campaign_path(cid, entity_id)
857
+ response = service.mutate_campaigns(
858
+ customer_id=cid, operations=[operation]
859
+ )
860
+
861
+ elif entity_type == "ad_group":
862
+ service = client.get_service("AdGroupService")
863
+ operation = client.get_type("AdGroupOperation")
864
+ operation.remove = service.ad_group_path(cid, entity_id)
865
+ response = service.mutate_ad_groups(
866
+ customer_id=cid, operations=[operation]
867
+ )
868
+
869
+ elif entity_type == "ad":
870
+ service = client.get_service("AdGroupAdService")
871
+ operation = client.get_type("AdGroupAdOperation")
872
+ operation.remove = f"customers/{cid}/adGroupAds/{entity_id}"
873
+ response = service.mutate_ad_group_ads(
874
+ customer_id=cid, operations=[operation]
875
+ )
876
+
877
+ elif entity_type == "keyword":
878
+ service = client.get_service("AdGroupCriterionService")
879
+ operation = client.get_type("AdGroupCriterionOperation")
880
+ operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}"
881
+ response = service.mutate_ad_group_criteria(
882
+ customer_id=cid, operations=[operation]
883
+ )
884
+
885
+ elif entity_type == "negative_keyword":
886
+ service = client.get_service("CampaignCriterionService")
887
+ operation = client.get_type("CampaignCriterionOperation")
888
+ operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}"
889
+ response = service.mutate_campaign_criteria(
890
+ customer_id=cid, operations=[operation]
891
+ )
892
+
893
+ else:
894
+ raise ValueError(f"Cannot remove entity_type: {entity_type}")
895
+
896
+ return {"resource_name": response.results[0].resource_name}
897
+
898
+
899
+ def _apply_status_change(
900
+ client: object,
901
+ cid: str,
902
+ entity_type: str,
903
+ entity_id: str,
904
+ status: str,
905
+ ) -> dict:
906
+ """Update the status of a campaign, ad group, ad, or keyword."""
907
+ if entity_type == "campaign":
908
+ service = client.get_service("CampaignService")
909
+ operation = client.get_type("CampaignOperation")
910
+ entity = operation.update
911
+ entity.resource_name = service.campaign_path(cid, entity_id)
912
+ entity.status = getattr(client.enums.CampaignStatusEnum, status)
913
+ mutate = service.mutate_campaigns
914
+
915
+ elif entity_type == "ad_group":
916
+ service = client.get_service("AdGroupService")
917
+ operation = client.get_type("AdGroupOperation")
918
+ entity = operation.update
919
+ entity.resource_name = service.ad_group_path(cid, entity_id)
920
+ entity.status = getattr(client.enums.AdGroupStatusEnum, status)
921
+ mutate = service.mutate_ad_groups
922
+
923
+ elif entity_type == "ad":
924
+ service = client.get_service("AdGroupAdService")
925
+ operation = client.get_type("AdGroupAdOperation")
926
+ entity = operation.update
927
+ entity.resource_name = f"customers/{cid}/adGroupAds/{entity_id}"
928
+ entity.status = getattr(client.enums.AdGroupAdStatusEnum, status)
929
+ mutate = service.mutate_ad_group_ads
930
+
931
+ elif entity_type == "keyword":
932
+ service = client.get_service("AdGroupCriterionService")
933
+ operation = client.get_type("AdGroupCriterionOperation")
934
+ entity = operation.update
935
+ entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}"
936
+ entity.status = getattr(
937
+ client.enums.AdGroupCriterionStatusEnum, status
938
+ )
939
+ mutate = service.mutate_ad_group_criteria
940
+
941
+ else:
942
+ raise ValueError(f"Unknown entity_type: {entity_type}")
943
+
944
+ # Build field mask for the status field only
945
+ field_mask = client.get_type("FieldMask")
946
+ field_mask.paths.append("status")
947
+ client.copy_from(operation.update_mask, field_mask)
948
+
949
+ response = mutate(customer_id=cid, operations=[operation])
950
+ return {"resource_name": response.results[0].resource_name}