adloop 0.35.0__tar.gz → 0.36.0__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.
- {adloop-0.35.0 → adloop-0.36.0}/PKG-INFO +5 -3
- {adloop-0.35.0 → adloop-0.36.0}/README.md +4 -2
- {adloop-0.35.0 → adloop-0.36.0}/pyproject.toml +1 -1
- {adloop-0.35.0 → adloop-0.36.0}/pyproject.toml.orig +1 -1
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/assets.py +35 -4
- adloop-0.36.0/src/adloop/ads/campaign_settings.py +222 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/read.py +4 -0
- adloop-0.36.0/src/adloop/ads/recommendations.py +210 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/write.py +148 -2
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/cli.py +10 -7
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/adloop.md +8 -3
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules_install.py +66 -22
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/server.py +101 -7
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/__main__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/snippet_headers.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/auth.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/config.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/crossref.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/runtime.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/tracking.py +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.35.0 → adloop-0.36.0}/src/adloop/uploads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.36.0
|
|
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
|
|
@@ -148,6 +148,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
148
148
|
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
149
149
|
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
150
150
|
| `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
|
|
151
|
+
| `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
|
|
151
152
|
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
152
153
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
153
154
|
|
|
@@ -334,6 +335,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
334
335
|
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
335
336
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
336
337
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
338
|
+
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
337
339
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
338
340
|
| `enable_entity` | Re-enable a paused entity |
|
|
339
341
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -523,8 +525,8 @@ adloop install-rules
|
|
|
523
525
|
|
|
524
526
|
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
525
527
|
|
|
526
|
-
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but
|
|
527
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
528
|
+
- **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
|
|
529
|
+
- **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
|
|
528
530
|
|
|
529
531
|
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
530
532
|
|
|
@@ -120,6 +120,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
120
120
|
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
121
121
|
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
122
122
|
| `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
|
|
123
|
+
| `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
|
|
123
124
|
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
124
125
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
125
126
|
|
|
@@ -306,6 +307,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
306
307
|
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
307
308
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
308
309
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
310
|
+
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
309
311
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
310
312
|
| `enable_entity` | Re-enable a paused entity |
|
|
311
313
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -495,8 +497,8 @@ adloop install-rules
|
|
|
495
497
|
|
|
496
498
|
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
497
499
|
|
|
498
|
-
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but
|
|
499
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
500
|
+
- **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
|
|
501
|
+
- **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
|
|
500
502
|
|
|
501
503
|
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
502
504
|
|
|
@@ -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
|
|
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)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""One read for the settings that decide how far a Search campaign reaches.
|
|
2
|
+
|
|
3
|
+
A recurring audit ("has Google switched anything back on?") otherwise needs
|
|
4
|
+
``get_ai_max_settings`` plus hand-written GAQL for networks, location option,
|
|
5
|
+
campaign-level broad match and URL options. Every field below was checked
|
|
6
|
+
against the v25 ``campaign`` resource:
|
|
7
|
+
|
|
8
|
+
campaign.network_settings.target_google_search / target_search_network /
|
|
9
|
+
target_content_network / target_partner_search_network
|
|
10
|
+
campaign.geo_target_type_setting.positive_geo_target_type PRESENCE_OR_INTEREST,
|
|
11
|
+
SEARCH_INTEREST, PRESENCE
|
|
12
|
+
campaign.geo_target_type_setting.negative_geo_target_type PRESENCE_OR_INTEREST, PRESENCE
|
|
13
|
+
campaign.keyword_match_type BROAD, or unset
|
|
14
|
+
campaign.tracking_url_template, final_url_suffix, url_custom_parameters
|
|
15
|
+
campaign.bidding_strategy_type and the per-strategy targets
|
|
16
|
+
campaign_budget.amount_micros, explicitly_shared
|
|
17
|
+
|
|
18
|
+
The AI Max half comes from ``ai_max.read_ai_max_state`` unchanged, so both tools
|
|
19
|
+
always agree. Read-only: nothing here mutates.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from adloop.config import AdLoopConfig
|
|
28
|
+
|
|
29
|
+
_SETTINGS_SELECT = """
|
|
30
|
+
SELECT campaign.id, campaign.name, campaign.status,
|
|
31
|
+
campaign.advertising_channel_type,
|
|
32
|
+
campaign.bidding_strategy_type, campaign.bidding_strategy,
|
|
33
|
+
campaign.target_spend.cpc_bid_ceiling_micros,
|
|
34
|
+
campaign.maximize_conversions.target_cpa_micros,
|
|
35
|
+
campaign.target_cpa.target_cpa_micros,
|
|
36
|
+
campaign.target_roas.target_roas,
|
|
37
|
+
campaign.maximize_conversion_value.target_roas,
|
|
38
|
+
campaign.network_settings.target_google_search,
|
|
39
|
+
campaign.network_settings.target_search_network,
|
|
40
|
+
campaign.network_settings.target_content_network,
|
|
41
|
+
campaign.geo_target_type_setting.positive_geo_target_type,
|
|
42
|
+
campaign.geo_target_type_setting.negative_geo_target_type,
|
|
43
|
+
campaign.keyword_match_type,
|
|
44
|
+
campaign.tracking_url_template, campaign.final_url_suffix,
|
|
45
|
+
campaign.url_custom_parameters,
|
|
46
|
+
campaign_budget.amount_micros, campaign_budget.explicitly_shared
|
|
47
|
+
FROM campaign
|
|
48
|
+
WHERE {where}
|
|
49
|
+
ORDER BY campaign.name
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_SEARCH_ONLY = "campaign.advertising_channel_type = 'SEARCH'"
|
|
53
|
+
_WHERE_ALL = f"campaign.status != 'REMOVED' AND {_SEARCH_ONLY}"
|
|
54
|
+
|
|
55
|
+
# Google's default; it also reaches people elsewhere who search *about* the
|
|
56
|
+
# targeted locations.
|
|
57
|
+
_WIDE_LOCATION_OPTIONS = {"PRESENCE_OR_INTEREST", "SEARCH_INTEREST"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _money(micros: object) -> float | None:
|
|
61
|
+
"""Micros to account currency; 0 or missing means "not set"."""
|
|
62
|
+
if not micros:
|
|
63
|
+
return None
|
|
64
|
+
return round(int(micros) / 1_000_000, 2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_cpa(row: dict) -> float | None:
|
|
68
|
+
return _money(
|
|
69
|
+
row.get("campaign.target_cpa.target_cpa_micros")
|
|
70
|
+
or row.get("campaign.maximize_conversions.target_cpa_micros")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _target_roas(row: dict) -> float | None:
|
|
75
|
+
value = row.get("campaign.target_roas.target_roas") or row.get(
|
|
76
|
+
"campaign.maximize_conversion_value.target_roas"
|
|
77
|
+
)
|
|
78
|
+
return value or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _custom_parameters(value: object) -> list[dict]:
|
|
82
|
+
if not isinstance(value, list):
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
{"key": item.get("key", ""), "value": item.get("value", "")}
|
|
86
|
+
for item in value
|
|
87
|
+
if isinstance(item, dict)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _widening(campaign: dict) -> list[str]:
|
|
92
|
+
"""The settings on this campaign that let ads reach beyond what was chosen."""
|
|
93
|
+
found: list[str] = []
|
|
94
|
+
if campaign["search_partners"]:
|
|
95
|
+
found.append("search partners on")
|
|
96
|
+
if campaign["display_network"]:
|
|
97
|
+
found.append("Display network on")
|
|
98
|
+
if campaign["location_option"] in _WIDE_LOCATION_OPTIONS:
|
|
99
|
+
found.append(f"location option {campaign['location_option']}")
|
|
100
|
+
if campaign["keyword_match_type"] == "BROAD":
|
|
101
|
+
found.append("campaign-level broad match on")
|
|
102
|
+
if campaign["enable_ai_max"]:
|
|
103
|
+
found.append("AI Max on")
|
|
104
|
+
for automation, status in campaign["asset_automation"].items():
|
|
105
|
+
if status == "OPTED_IN":
|
|
106
|
+
found.append(f"{automation} opted in")
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_campaign_settings(
|
|
111
|
+
config: AdLoopConfig,
|
|
112
|
+
*,
|
|
113
|
+
customer_id: str = "",
|
|
114
|
+
campaign_id: str = "",
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""Read-only, one flat record per Search campaign. See the module docstring."""
|
|
117
|
+
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
118
|
+
from adloop.ads.currency import get_currency_code
|
|
119
|
+
|
|
120
|
+
campaign_id = str(campaign_id or "").strip()
|
|
121
|
+
if campaign_id and not campaign_id.isdigit():
|
|
122
|
+
return {"error": "campaign_id must be a numeric ID"}
|
|
123
|
+
|
|
124
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
125
|
+
client = get_ads_client(config)
|
|
126
|
+
result = read_campaign_settings(client, cid, campaign_id=campaign_id)
|
|
127
|
+
result["currency"] = get_currency_code(config, cid)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_campaign_settings(client: object, cid: str, *, campaign_id: str = "") -> dict:
|
|
132
|
+
"""Run both reads and merge them per campaign."""
|
|
133
|
+
from adloop.ads.ai_max import _search, read_ai_max_state
|
|
134
|
+
|
|
135
|
+
if campaign_id and not str(campaign_id).isdigit():
|
|
136
|
+
# The id goes into the query text; anything else must not reach GAQL.
|
|
137
|
+
raise ValueError("campaign_id must be a numeric ID")
|
|
138
|
+
# An explicit ID stays Search-only too: the AI Max and location settings
|
|
139
|
+
# read here don't apply to Performance Max or Display campaigns.
|
|
140
|
+
where = f"campaign.id = {campaign_id} AND {_SEARCH_ONLY}" if campaign_id else _WHERE_ALL
|
|
141
|
+
rows = _search(
|
|
142
|
+
client.get_service("GoogleAdsService"), cid, _SETTINGS_SELECT.format(where=where)
|
|
143
|
+
)
|
|
144
|
+
ai_max = {
|
|
145
|
+
c["campaign_id"]: c
|
|
146
|
+
for c in read_ai_max_state(client, cid, campaign_id=campaign_id)["campaigns"]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
campaigns: list[dict] = []
|
|
150
|
+
for row in rows:
|
|
151
|
+
key = str(row.get("campaign.id", ""))
|
|
152
|
+
if not key or (campaign_id and key != str(campaign_id)):
|
|
153
|
+
continue
|
|
154
|
+
ai = ai_max.get(key, {})
|
|
155
|
+
campaign = {
|
|
156
|
+
"campaign_id": key,
|
|
157
|
+
"campaign_name": row.get("campaign.name"),
|
|
158
|
+
"status": row.get("campaign.status"),
|
|
159
|
+
"advertising_channel_type": row.get("campaign.advertising_channel_type"),
|
|
160
|
+
"bidding_strategy_type": row.get("campaign.bidding_strategy_type"),
|
|
161
|
+
"portfolio_bidding_strategy": row.get("campaign.bidding_strategy") or None,
|
|
162
|
+
"cpc_bid_ceiling": _money(
|
|
163
|
+
row.get("campaign.target_spend.cpc_bid_ceiling_micros")
|
|
164
|
+
),
|
|
165
|
+
"target_cpa": _target_cpa(row),
|
|
166
|
+
"target_roas": _target_roas(row),
|
|
167
|
+
"daily_budget": _money(row.get("campaign_budget.amount_micros")),
|
|
168
|
+
"budget_shared": bool(row.get("campaign_budget.explicitly_shared")),
|
|
169
|
+
"google_search": bool(row.get("campaign.network_settings.target_google_search")),
|
|
170
|
+
"search_partners": bool(
|
|
171
|
+
row.get("campaign.network_settings.target_search_network")
|
|
172
|
+
),
|
|
173
|
+
"display_network": bool(
|
|
174
|
+
row.get("campaign.network_settings.target_content_network")
|
|
175
|
+
),
|
|
176
|
+
"location_option": row.get(
|
|
177
|
+
"campaign.geo_target_type_setting.positive_geo_target_type"
|
|
178
|
+
),
|
|
179
|
+
"negative_location_option": row.get(
|
|
180
|
+
"campaign.geo_target_type_setting.negative_geo_target_type"
|
|
181
|
+
),
|
|
182
|
+
"keyword_match_type": row.get("campaign.keyword_match_type"),
|
|
183
|
+
"tracking_url_template": row.get("campaign.tracking_url_template") or "",
|
|
184
|
+
"final_url_suffix": row.get("campaign.final_url_suffix") or "",
|
|
185
|
+
"url_custom_parameters": _custom_parameters(
|
|
186
|
+
row.get("campaign.url_custom_parameters")
|
|
187
|
+
),
|
|
188
|
+
"enable_ai_max": bool(ai.get("enable_ai_max")),
|
|
189
|
+
"asset_automation": {
|
|
190
|
+
item["asset_automation_type"]: item.get("asset_automation_status", "")
|
|
191
|
+
for item in ai.get("asset_automation_settings", [])
|
|
192
|
+
},
|
|
193
|
+
# Search term matching only acts under AI Max; listing every ad
|
|
194
|
+
# group's default False otherwise would be noise.
|
|
195
|
+
"ad_groups_with_search_term_matching": [
|
|
196
|
+
g["ad_group_name"]
|
|
197
|
+
for g in ai.get("ad_groups", [])
|
|
198
|
+
if g.get("disable_search_term_matching") is False
|
|
199
|
+
] if ai.get("enable_ai_max") else [],
|
|
200
|
+
}
|
|
201
|
+
campaign["widening_settings"] = _widening(campaign)
|
|
202
|
+
campaigns.append(campaign)
|
|
203
|
+
|
|
204
|
+
insights: list[str] = []
|
|
205
|
+
for campaign in campaigns:
|
|
206
|
+
if campaign["widening_settings"]:
|
|
207
|
+
insights.append(
|
|
208
|
+
f"{campaign['campaign_name']} ({campaign['campaign_id']}): "
|
|
209
|
+
+ ", ".join(campaign["widening_settings"])
|
|
210
|
+
+ ". These let the ads reach beyond the keywords and locations "
|
|
211
|
+
"chosen; confirm they are intended."
|
|
212
|
+
)
|
|
213
|
+
if campaigns and not insights:
|
|
214
|
+
insights.append("No setting that widens reach is on in any campaign returned.")
|
|
215
|
+
if not campaigns:
|
|
216
|
+
insights.append("No non-removed Search campaign matched.")
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"campaigns": campaigns,
|
|
220
|
+
"total_campaigns": len(campaigns),
|
|
221
|
+
"insights": insights,
|
|
222
|
+
}
|
|
@@ -843,6 +843,10 @@ def get_recommendations(
|
|
|
843
843
|
pot_conversions = _round_metric(getattr(pot, "conversions", 0))
|
|
844
844
|
|
|
845
845
|
entry = {
|
|
846
|
+
# The ID (last segment of the resource name) is what
|
|
847
|
+
# draft_dismiss_recommendations takes.
|
|
848
|
+
"recommendation.id": rec.resource_name.rsplit("/", 1)[-1],
|
|
849
|
+
"recommendation.resource_name": rec.resource_name,
|
|
846
850
|
"recommendation.type": rec_type,
|
|
847
851
|
"recommendation.campaign": rec.campaign,
|
|
848
852
|
"recommendation.ad_group": rec.ad_group or "",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Dismiss Google Ads recommendations.
|
|
2
|
+
|
|
3
|
+
``get_recommendations`` (in ``ads/read.py``) lists the active ones; this module
|
|
4
|
+
turns a chosen subset down. The v25 call (verified against the SDK):
|
|
5
|
+
|
|
6
|
+
RecommendationService.DismissRecommendation
|
|
7
|
+
DismissRecommendationRequest{customer_id, operations[], partial_failure}
|
|
8
|
+
DismissRecommendationOperation{resource_name}
|
|
9
|
+
|
|
10
|
+
The request has **no** ``validate_only`` field, so a dry run cannot hand the
|
|
11
|
+
exact call to Google the way other Google Ads writes do. Instead the dry run
|
|
12
|
+
re-reads the account and checks that every planned recommendation is still
|
|
13
|
+
active (``preflight``), the same approach the Reddit, Tag Manager and GA4
|
|
14
|
+
writes use. The validate-only client still refuses the call outright, so this
|
|
15
|
+
path can never dismiss anything during a dry run.
|
|
16
|
+
|
|
17
|
+
Dismissing is mild: nothing in the account changes, and Google can show the
|
|
18
|
+
recommendation again later. A single confirmation is enough.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from adloop.config import AdLoopConfig
|
|
28
|
+
|
|
29
|
+
OPERATION = "dismiss_recommendations"
|
|
30
|
+
|
|
31
|
+
# A recommendation ID is the last segment of its resource name. Google does not
|
|
32
|
+
# document the alphabet, so accept what resource names can carry and nothing
|
|
33
|
+
# that could break out of one.
|
|
34
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9_~=+-]+$")
|
|
35
|
+
_RESOURCE_RE = re.compile(r"^customers/(\d+)/recommendations/([A-Za-z0-9_~=+-]+)$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def recommendation_id(resource_name: str) -> str:
|
|
39
|
+
"""``customers/1/recommendations/abc`` -> ``abc``."""
|
|
40
|
+
return str(resource_name or "").rsplit("/", 1)[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean_ids(values: list[str] | None, cid: str) -> tuple[list[str], list[str]]:
|
|
44
|
+
"""Accept bare IDs or full resource names. Returns (errors, deduped IDs)."""
|
|
45
|
+
errors: list[str] = []
|
|
46
|
+
ids: list[str] = []
|
|
47
|
+
for raw in values or []:
|
|
48
|
+
value = str(raw or "").strip()
|
|
49
|
+
if not value:
|
|
50
|
+
continue
|
|
51
|
+
match = _RESOURCE_RE.match(value)
|
|
52
|
+
if match:
|
|
53
|
+
if match.group(1) != cid:
|
|
54
|
+
errors.append(
|
|
55
|
+
f"{value} belongs to account {match.group(1)}, not {cid}"
|
|
56
|
+
)
|
|
57
|
+
continue
|
|
58
|
+
value = match.group(2)
|
|
59
|
+
elif not _ID_RE.match(value):
|
|
60
|
+
errors.append(f"'{value}' is not a recommendation ID or resource name")
|
|
61
|
+
continue
|
|
62
|
+
if value not in ids:
|
|
63
|
+
ids.append(value)
|
|
64
|
+
return errors, ids
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _active_by_id(config: AdLoopConfig, customer_id: str) -> dict[str, dict]:
|
|
68
|
+
"""Every active (not dismissed) recommendation, keyed by ID."""
|
|
69
|
+
from adloop.ads.read import get_recommendations
|
|
70
|
+
|
|
71
|
+
rows = get_recommendations(config, customer_id=customer_id).get(
|
|
72
|
+
"recommendations", []
|
|
73
|
+
)
|
|
74
|
+
return {
|
|
75
|
+
recommendation_id(row.get("recommendation.resource_name", "")): row
|
|
76
|
+
for row in rows
|
|
77
|
+
if row.get("recommendation.resource_name")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def draft_dismiss_recommendations(
|
|
82
|
+
config: AdLoopConfig,
|
|
83
|
+
*,
|
|
84
|
+
customer_id: str = "",
|
|
85
|
+
recommendation_ids: list[str] | None = None,
|
|
86
|
+
) -> dict:
|
|
87
|
+
"""Draft dismissing recommendations — returns PREVIEW.
|
|
88
|
+
|
|
89
|
+
Reads the active recommendations first: an ID that is unknown or already
|
|
90
|
+
dismissed is refused by name, so the preview only ever lists what the
|
|
91
|
+
apply will turn down. Each entry carries its type, campaign and Google's
|
|
92
|
+
estimated improvement, the same numbers ``get_recommendations`` shows.
|
|
93
|
+
|
|
94
|
+
Call ``confirm_and_apply`` with the returned plan_id to execute.
|
|
95
|
+
"""
|
|
96
|
+
from adloop.ads.client import normalize_customer_id
|
|
97
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
98
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
check_blocked_operation(OPERATION, config.safety)
|
|
102
|
+
except SafetyViolation as e:
|
|
103
|
+
return {"error": str(e)}
|
|
104
|
+
|
|
105
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
106
|
+
errors, ids = _clean_ids(recommendation_ids, cid)
|
|
107
|
+
if not ids and not errors:
|
|
108
|
+
errors.append("At least one recommendation_id is required")
|
|
109
|
+
if errors:
|
|
110
|
+
return {"error": "Validation failed", "details": errors}
|
|
111
|
+
|
|
112
|
+
active = _active_by_id(config, customer_id)
|
|
113
|
+
missing = [rec_id for rec_id in ids if rec_id not in active]
|
|
114
|
+
if missing:
|
|
115
|
+
return {
|
|
116
|
+
"error": "Validation failed",
|
|
117
|
+
"details": [
|
|
118
|
+
"Not an active recommendation in this account (unknown, or "
|
|
119
|
+
"already dismissed): " + ", ".join(missing)
|
|
120
|
+
+ ". get_recommendations lists the active ones with their IDs."
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
recommendations = [
|
|
125
|
+
{
|
|
126
|
+
"recommendation_id": rec_id,
|
|
127
|
+
"resource_name": active[rec_id]["recommendation.resource_name"],
|
|
128
|
+
"type": active[rec_id].get("recommendation.type", ""),
|
|
129
|
+
"campaign": active[rec_id].get("recommendation.campaign", ""),
|
|
130
|
+
"ad_group": active[rec_id].get("recommendation.ad_group", ""),
|
|
131
|
+
"estimated_improvement": active[rec_id].get("estimated_improvement", {}),
|
|
132
|
+
}
|
|
133
|
+
for rec_id in ids
|
|
134
|
+
]
|
|
135
|
+
plan = ChangePlan(
|
|
136
|
+
operation=OPERATION,
|
|
137
|
+
entity_type="recommendation",
|
|
138
|
+
entity_id=",".join(ids),
|
|
139
|
+
customer_id=customer_id,
|
|
140
|
+
changes={
|
|
141
|
+
"recommendations": recommendations,
|
|
142
|
+
"count": len(recommendations),
|
|
143
|
+
"note": (
|
|
144
|
+
"Dismissing changes nothing in the account. Google can show a "
|
|
145
|
+
"dismissed recommendation again later."
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
store_plan(plan)
|
|
150
|
+
return plan.to_preview()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def preflight(config: AdLoopConfig, plan: object) -> dict:
|
|
154
|
+
"""Dry run: every planned recommendation must still be active.
|
|
155
|
+
|
|
156
|
+
DismissRecommendation has no validate-only mode, so this re-read is the
|
|
157
|
+
whole check. Raises when any recommendation has gone (dismissed in the UI,
|
|
158
|
+
applied, or expired), so the dry run fails instead of reporting success.
|
|
159
|
+
"""
|
|
160
|
+
planned = [r["recommendation_id"] for r in plan.changes["recommendations"]]
|
|
161
|
+
active = _active_by_id(config, plan.customer_id)
|
|
162
|
+
gone = [rec_id for rec_id in planned if rec_id not in active]
|
|
163
|
+
if gone:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"No longer active (dismissed, applied or expired since the "
|
|
166
|
+
"preview): " + ", ".join(gone) + ". Draft again."
|
|
167
|
+
)
|
|
168
|
+
return {"recommendations_checked": len(planned), "still_active": len(planned)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _apply_dismiss_recommendations(client: object, cid: str, changes: dict) -> dict:
|
|
172
|
+
"""Dismiss the planned recommendations; per-recommendation errors are reported."""
|
|
173
|
+
from adloop.ads.write import _parse_partial_failure_per_op
|
|
174
|
+
|
|
175
|
+
names = [r["resource_name"] for r in changes["recommendations"]]
|
|
176
|
+
service = client.get_service("RecommendationService")
|
|
177
|
+
request = client.get_type("DismissRecommendationRequest")
|
|
178
|
+
request.customer_id = cid
|
|
179
|
+
request.partial_failure = True
|
|
180
|
+
operation_type = type(request).DismissRecommendationOperation
|
|
181
|
+
for name in names:
|
|
182
|
+
request.operations.append(operation_type(resource_name=name))
|
|
183
|
+
|
|
184
|
+
response = service.dismiss_recommendation(request=request)
|
|
185
|
+
|
|
186
|
+
pf_error = getattr(response, "partial_failure_error", None)
|
|
187
|
+
per_op_errors = _parse_partial_failure_per_op(client, pf_error)
|
|
188
|
+
dismissed: list[str] = []
|
|
189
|
+
failed: list[dict] = []
|
|
190
|
+
for index, result in enumerate(response.results):
|
|
191
|
+
if result.resource_name:
|
|
192
|
+
dismissed.append(result.resource_name)
|
|
193
|
+
else:
|
|
194
|
+
failed.append(
|
|
195
|
+
{
|
|
196
|
+
"recommendation_id": recommendation_id(names[index]),
|
|
197
|
+
"error": per_op_errors.get(
|
|
198
|
+
index, "Unknown error (see partial_failure_message)"
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
out: dict = {"dismissed": dismissed, "dismissed_count": len(dismissed)}
|
|
204
|
+
if failed:
|
|
205
|
+
out["partial_failure"] = True
|
|
206
|
+
out["failed"] = failed
|
|
207
|
+
message = getattr(pf_error, "message", "") if pf_error is not None else ""
|
|
208
|
+
if message:
|
|
209
|
+
out["partial_failure_message"] = message
|
|
210
|
+
return out
|