Skip to content

Commit 8c2aa96

Browse files
NagyViktclaude
andcommitted
PR 17: sync v3 — ad groups, keywords, asset-group text assets, RSAs
Lifts declarative state coverage from "account-as-code" to nearly full-account-as-code. New entity kinds in the schema (all opt-in — omit a top-level array to leave a kind unmanaged): [[ad_groups]] # readonly view; status edits via set-status [[keywords]] # ad_group_criterion keywords (CREATE/PRUNE) [[asset_group_text_assets]] # HEADLINE/DESCRIPTION/LONG_HEADLINE per AG [[responsive_search_ads]] # inline headlines + descriptions Workflow: gads export --full -o state.toml # NEW: --full pulls v3 entities $EDITOR state.toml # add a [[keywords]] without id gads plan state.toml # shows + create gads sync state.toml --apply # creates the keyword Each kind follows the same "id present = match, id missing = CREATE, live extra = PRUNE (with --prune)" rules established in v2. RSAs treat their inline headlines/descriptions as immutable text — to change RSA copy, prune the existing one and create a new one in the same file. Live verification: a `--full` export of the teherguminet account produced 98 entity blocks (2 campaigns + 13 account assets + 1 ad group + 6 keywords + 75 asset-group text assets + 1 RSA). Round-trip plan against live shows zero drift. 5 new tests cover create/prune for each new kind plus the "omitted key means unmanaged" rule. 35/35 pytest in 0.03s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7bcca23 commit 8c2aa96

2 files changed

Lines changed: 331 additions & 9 deletions

File tree

gads

Lines changed: 243 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,99 @@ def cmd_suggest(args):
21332133
# copy, and account-level assets are roadmap. The export format is TOML
21342134
# so it's hand-editable + diff-friendly in git.
21352135

2136+
def _live_ad_groups_state(customer_id):
2137+
"""Snapshot enabled/paused ad groups (SEARCH and SEARCH-like campaigns)."""
2138+
res = api_call("POST", "googleAds:search", {"query": """
2139+
SELECT ad_group.id, ad_group.name, ad_group.status, campaign.id
2140+
FROM ad_group
2141+
WHERE ad_group.status != 'REMOVED'
2142+
"""}, customer_id)
2143+
out = []
2144+
for row in res.get("results", []):
2145+
ag = row["adGroup"]
2146+
out.append({
2147+
"id": ag["id"],
2148+
"name": ag["name"],
2149+
"status": ag["status"],
2150+
"campaign_id": row["campaign"]["id"],
2151+
})
2152+
return out
2153+
2154+
2155+
def _live_keywords_state(customer_id):
2156+
"""Snapshot ad_group_criterion keywords."""
2157+
res = api_call("POST", "googleAds:search", {"query": """
2158+
SELECT ad_group_criterion.criterion_id,
2159+
ad_group_criterion.keyword.text,
2160+
ad_group_criterion.keyword.match_type,
2161+
ad_group_criterion.status,
2162+
ad_group.id
2163+
FROM ad_group_criterion
2164+
WHERE ad_group_criterion.type = 'KEYWORD'
2165+
AND ad_group_criterion.status != 'REMOVED'
2166+
"""}, customer_id)
2167+
out = []
2168+
for row in res.get("results", []):
2169+
agc = row["adGroupCriterion"]
2170+
out.append({
2171+
"id": agc.get("criterionId"),
2172+
"ad_group_id": row["adGroup"]["id"],
2173+
"text": agc.get("keyword", {}).get("text", ""),
2174+
"match_type": agc.get("keyword", {}).get("matchType", ""),
2175+
"status": agc.get("status", "ENABLED"),
2176+
})
2177+
return out
2178+
2179+
2180+
def _live_asset_group_text_assets_state(customer_id):
2181+
"""Snapshot HEADLINE/DESCRIPTION/LONG_HEADLINE assets per asset group."""
2182+
res = api_call("POST", "googleAds:search", {"query": """
2183+
SELECT asset.id, asset.text_asset.text,
2184+
asset_group.id, asset_group_asset.field_type
2185+
FROM asset_group_asset
2186+
WHERE asset_group_asset.status != 'REMOVED'
2187+
AND asset_group_asset.field_type IN ('HEADLINE', 'DESCRIPTION', 'LONG_HEADLINE')
2188+
"""}, customer_id)
2189+
out = []
2190+
for row in res.get("results", []):
2191+
out.append({
2192+
"id": row["asset"]["id"],
2193+
"asset_group_id": row["assetGroup"]["id"],
2194+
"field_type": row["assetGroupAsset"]["fieldType"],
2195+
"text": row["asset"].get("textAsset", {}).get("text", ""),
2196+
})
2197+
return out
2198+
2199+
2200+
def _live_responsive_search_ads_state(customer_id):
2201+
"""Snapshot RSAs (inline headlines + descriptions)."""
2202+
res = api_call("POST", "googleAds:search", {"query": """
2203+
SELECT ad_group_ad.ad.id,
2204+
ad_group_ad.ad.responsive_search_ad.headlines,
2205+
ad_group_ad.ad.responsive_search_ad.descriptions,
2206+
ad_group_ad.ad.final_urls,
2207+
ad_group_ad.status,
2208+
ad_group.id
2209+
FROM ad_group_ad
2210+
WHERE ad_group_ad.status != 'REMOVED'
2211+
"""}, customer_id)
2212+
out = []
2213+
for row in res.get("results", []):
2214+
ad = row["adGroupAd"]["ad"]
2215+
rsa = ad.get("responsiveSearchAd")
2216+
if not rsa: # only RSAs in v3
2217+
continue
2218+
out.append({
2219+
"id": ad["id"],
2220+
"ad_group_id": row["adGroup"]["id"],
2221+
"status": row["adGroupAd"]["status"],
2222+
"final_url": (ad.get("finalUrls") or [""])[0],
2223+
"headlines": [h.get("text", "") for h in rsa.get("headlines", [])],
2224+
"descriptions": [d.get("text", "") for d in rsa.get("descriptions", [])],
2225+
})
2226+
return out
2227+
2228+
21362229
def _live_account_assets_state(customer_id):
21372230
"""Snapshot account-scope sitelinks/callouts/snippets."""
21382231
res = api_call("POST", "googleAds:search", {"query": """
@@ -2258,6 +2351,46 @@ def _serialize_state_toml(state):
22582351
lines.append(f'values = [{values_str}]')
22592352
lines.append("")
22602353

2354+
# ---- v3: ad groups + keywords + asset-group text assets + RSAs ----
2355+
for ag in state.get("ad_groups", []):
2356+
lines.append("[[ad_groups]]")
2357+
lines.append(f'id = "{ag["id"]}" # readonly')
2358+
lines.append(f'campaign_id = "{ag["campaign_id"]}"')
2359+
lines.append(f'name = "{_toml_escape(ag.get("name", ""))}"')
2360+
lines.append(f'status = "{ag.get("status", "ENABLED")}"')
2361+
lines.append("")
2362+
2363+
for kw in state.get("keywords", []):
2364+
lines.append("[[keywords]]")
2365+
if kw.get("id"):
2366+
lines.append(f'id = "{kw["id"]}"')
2367+
lines.append(f'ad_group_id = "{kw["ad_group_id"]}"')
2368+
lines.append(f'text = "{_toml_escape(kw["text"])}"')
2369+
lines.append(f'match_type = "{kw["match_type"]}"')
2370+
lines.append("")
2371+
2372+
for ta in state.get("asset_group_text_assets", []):
2373+
lines.append("[[asset_group_text_assets]]")
2374+
if ta.get("id"):
2375+
lines.append(f'id = "{ta["id"]}"')
2376+
lines.append(f'asset_group_id = "{ta["asset_group_id"]}"')
2377+
lines.append(f'field_type = "{ta["field_type"]}" # HEADLINE | DESCRIPTION | LONG_HEADLINE')
2378+
lines.append(f'text = "{_toml_escape(ta["text"])}"')
2379+
lines.append("")
2380+
2381+
for rsa in state.get("responsive_search_ads", []):
2382+
lines.append("[[responsive_search_ads]]")
2383+
if rsa.get("id"):
2384+
lines.append(f'id = "{rsa["id"]}" # readonly')
2385+
lines.append(f'ad_group_id = "{rsa["ad_group_id"]}"')
2386+
lines.append(f'status = "{rsa.get("status", "ENABLED")}"')
2387+
lines.append(f'final_url = "{_toml_escape(rsa.get("final_url", ""))}"')
2388+
headlines_arr = ", ".join(f'"{_toml_escape(h)}"' for h in rsa.get("headlines", []))
2389+
lines.append(f'headlines = [{headlines_arr}]')
2390+
descs_arr = ", ".join(f'"{_toml_escape(d)}"' for d in rsa.get("descriptions", []))
2391+
lines.append(f'descriptions = [{descs_arr}]')
2392+
lines.append("")
2393+
22612394
return "\n".join(lines)
22622395

22632396

@@ -2268,13 +2401,25 @@ def cmd_export(args):
22682401
"campaigns": _live_campaigns_state(args.customer),
22692402
**assets,
22702403
}
2404+
if args.full:
2405+
state["ad_groups"] = _live_ad_groups_state(args.customer)
2406+
state["keywords"] = _live_keywords_state(args.customer)
2407+
state["asset_group_text_assets"] = _live_asset_group_text_assets_state(args.customer)
2408+
state["responsive_search_ads"] = _live_responsive_search_ads_state(args.customer)
22712409
serialized = _serialize_state_toml(state)
22722410
counts = (
22732411
f"{len(state['campaigns'])} campaign(s), "
22742412
f"{len(state['sitelinks'])} sitelink(s), "
22752413
f"{len(state['callouts'])} callout(s), "
22762414
f"{len(state['snippets'])} snippet(s)"
22772415
)
2416+
if args.full:
2417+
counts += (
2418+
f", {len(state['ad_groups'])} ad group(s), "
2419+
f"{len(state['keywords'])} keyword(s), "
2420+
f"{len(state['asset_group_text_assets'])} ag text-asset(s), "
2421+
f"{len(state['responsive_search_ads'])} RSA(s)"
2422+
)
22782423
if args.output and args.output != "-":
22792424
Path(args.output).write_text(serialized)
22802425
print(f"Wrote {args.output} ({counts})")
@@ -2355,15 +2500,41 @@ def _compute_diff_states(desired_state, current_state):
23552500
"operation": "prune",
23562501
"data": live_entry,
23572502
})
2503+
2504+
# ---- v3: ad_group_text_assets, keywords, RSAs (id-keyed, create/prune) ----
2505+
for plural, singular in (
2506+
("keywords", "keyword"),
2507+
("asset_group_text_assets", "asset_group_text_asset"),
2508+
("responsive_search_ads", "responsive_search_ad"),
2509+
):
2510+
if plural not in desired_state:
2511+
continue
2512+
desired_list = desired_state[plural]
2513+
live_list = current_state.get(plural, [])
2514+
state_ids = {e["id"] for e in desired_list if e.get("id")}
2515+
for entry in desired_list:
2516+
if not entry.get("id"):
2517+
changes.append({"kind": singular, "operation": "create", "data": entry})
2518+
for live_entry in live_list:
2519+
if live_entry["id"] not in state_ids:
2520+
changes.append({"kind": singular, "operation": "prune", "data": live_entry})
23582521
return changes
23592522

23602523

23612524
def _compute_diff(state_file, customer_id):
2362-
"""Diff a state file against the live Google Ads account."""
2525+
"""Diff a state file against the live Google Ads account. Only fetches
2526+
the v3 entity types if the state file mentions them — keeps the simple
2527+
case (campaigns + account assets) fast."""
23632528
live_state = {
23642529
"campaigns": _live_campaigns_state(customer_id),
23652530
**_live_account_assets_state(customer_id),
23662531
}
2532+
v3_keys = ("ad_groups", "keywords", "asset_group_text_assets", "responsive_search_ads")
2533+
if any(k in state_file for k in v3_keys):
2534+
live_state["ad_groups"] = _live_ad_groups_state(customer_id)
2535+
live_state["keywords"] = _live_keywords_state(customer_id)
2536+
live_state["asset_group_text_assets"] = _live_asset_group_text_assets_state(customer_id)
2537+
live_state["responsive_search_ads"] = _live_responsive_search_ads_state(customer_id)
23672538
return _compute_diff_states(state_file, live_state)
23682539

23692540

@@ -2395,6 +2566,24 @@ def _change_summary(ch):
23952566
return f"+ snippet {d.get('header','')}: {', '.join(d.get('values',[]))}"
23962567
if op == "prune":
23972568
return f"- snippet [{d['id']}] {d.get('header','')}"
2569+
if kind == "keyword":
2570+
d = ch["data"]
2571+
if op == "create":
2572+
return f"+ keyword [{d['match_type']}] '{d['text']}' → ad_group {d['ad_group_id']}"
2573+
if op == "prune":
2574+
return f"- keyword [{d['id']}] '{d['text']}' ({d['match_type']})"
2575+
if kind == "asset_group_text_asset":
2576+
d = ch["data"]
2577+
if op == "create":
2578+
return f"+ {d['field_type']:<13} '{d['text']}' → asset_group {d['asset_group_id']}"
2579+
if op == "prune":
2580+
return f"- {d['field_type']:<13} [{d['id']}] '{d['text']}'"
2581+
if kind == "responsive_search_ad":
2582+
d = ch["data"]
2583+
if op == "create":
2584+
return f"+ RSA ({len(d.get('headlines',[]))}h + {len(d.get('descriptions',[]))}d) → ad_group {d['ad_group_id']}"
2585+
if op == "prune":
2586+
return f"- RSA [{d['id']}] in ad_group {d['ad_group_id']}"
23982587
return f"? {kind} {op}"
23992588

24002589

@@ -2462,15 +2651,58 @@ def _apply_change(ch, customer_id):
24622651
}}, customer_id)
24632652
_link_to_customer(rn, "STRUCTURED_SNIPPET", customer_id)
24642653
return f"created snippet '{d['header']}'"
2654+
if op == "create":
2655+
d = ch["data"]
2656+
if kind == "keyword":
2657+
ag_rn = f"customers/{customer_id}/adGroups/{d['ad_group_id']}"
2658+
body = {"operations": [{"create": {
2659+
"adGroup": ag_rn, "status": "ENABLED",
2660+
"keyword": {"text": d["text"], "matchType": d["match_type"]},
2661+
}}]}
2662+
api_call("POST", "adGroupCriteria:mutate", body, customer_id)
2663+
return f"created keyword '{d['text']}' [{d['match_type']}]"
2664+
if kind == "asset_group_text_asset":
2665+
text_rn = _create_text_asset(d["text"], customer_id)
2666+
_link_asset_to_group(text_rn, d["asset_group_id"], d["field_type"], customer_id)
2667+
return f"created {d['field_type']} '{d['text']}'"
2668+
if kind == "responsive_search_ad":
2669+
ag_rn = f"customers/{customer_id}/adGroups/{d['ad_group_id']}"
2670+
body = {"operations": [{"create": {
2671+
"adGroup": ag_rn,
2672+
"status": d.get("status", "ENABLED"),
2673+
"ad": {
2674+
"finalUrls": [d["final_url"]],
2675+
"responsiveSearchAd": {
2676+
"headlines": [{"text": h} for h in d["headlines"]],
2677+
"descriptions": [{"text": dd} for dd in d["descriptions"]],
2678+
},
2679+
},
2680+
}}]}
2681+
api_call("POST", "adGroupAds:mutate", body, customer_id)
2682+
return f"created RSA in ad_group {d['ad_group_id']}"
24652683
if op == "prune":
24662684
d = ch["data"]
2467-
# Remove the customer_asset link (asset stays in library; can clean
2468-
# later via `gads cleanup-orphans`).
2469-
ft = {"sitelink": "SITELINK", "callout": "CALLOUT", "snippet": "STRUCTURED_SNIPPET"}[kind]
2470-
resource = f"customers/{customer_id}/customerAssets/{d['id']}~{ft}"
2471-
body = {"operations": [{"remove": resource}]}
2472-
api_call("POST", "customerAssets:mutate", body, customer_id)
2473-
return f"pruned {kind} [{d['id']}]"
2685+
if kind in ("sitelink", "callout", "snippet"):
2686+
ft = {"sitelink": "SITELINK", "callout": "CALLOUT", "snippet": "STRUCTURED_SNIPPET"}[kind]
2687+
resource = f"customers/{customer_id}/customerAssets/{d['id']}~{ft}"
2688+
body = {"operations": [{"remove": resource}]}
2689+
api_call("POST", "customerAssets:mutate", body, customer_id)
2690+
return f"pruned {kind} [{d['id']}]"
2691+
if kind == "keyword":
2692+
resource = f"customers/{customer_id}/adGroupCriteria/{d['ad_group_id']}~{d['id']}"
2693+
body = {"operations": [{"remove": resource}]}
2694+
api_call("POST", "adGroupCriteria:mutate", body, customer_id)
2695+
return f"pruned keyword [{d['id']}] '{d['text']}'"
2696+
if kind == "asset_group_text_asset":
2697+
resource = f"customers/{customer_id}/assetGroupAssets/{d['asset_group_id']}~{d['id']}~{d['field_type']}"
2698+
body = {"operations": [{"remove": resource}]}
2699+
api_call("POST", "assetGroupAssets:mutate", body, customer_id)
2700+
return f"unlinked {d['field_type']} [{d['id']}] from asset_group {d['asset_group_id']}"
2701+
if kind == "responsive_search_ad":
2702+
resource = f"customers/{customer_id}/adGroupAds/{d['ad_group_id']}~{d['id']}"
2703+
body = {"operations": [{"remove": resource}]}
2704+
api_call("POST", "adGroupAds:mutate", body, customer_id)
2705+
return f"pruned RSA [{d['id']}]"
24742706
return f"skipped {kind} {op}"
24752707

24762708

@@ -3110,9 +3342,11 @@ def main():
31103342
p.set_defaults(func=cmd_audit_log)
31113343

31123344
p = sub.add_parser("export",
3113-
help="Export live account state to a TOML file (v1: campaigns)")
3345+
help="Export live account state to a TOML file")
31143346
p.add_argument("-o", "--output", default="-",
31153347
help="Output file path (default: stdout)")
3348+
p.add_argument("--full", action="store_true",
3349+
help="Also export ad groups, keywords, asset-group text assets, and RSAs (v3 schema)")
31163350
p.set_defaults(func=cmd_export)
31173351

31183352
p = sub.add_parser("plan",

0 commit comments

Comments
 (0)