adloop 0.5.2__tar.gz → 0.6.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.
Files changed (28) hide show
  1. {adloop-0.5.2 → adloop-0.6.0}/PKG-INFO +1 -1
  2. {adloop-0.5.2 → adloop-0.6.0}/pyproject.toml +1 -1
  3. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/__init__.py +1 -1
  4. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/read.py +7 -1
  5. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/write.py +135 -3
  6. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/server.py +36 -1
  7. {adloop-0.5.2 → adloop-0.6.0}/README.md +0 -0
  8. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/__main__.py +0 -0
  9. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/__init__.py +0 -0
  10. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/client.py +0 -0
  11. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/currency.py +0 -0
  12. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/forecast.py +0 -0
  13. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/gaql.py +0 -0
  14. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ads/pmax.py +0 -0
  15. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/auth.py +0 -0
  16. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/bundled_credentials.json +0 -0
  17. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/cli.py +0 -0
  18. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/config.py +0 -0
  19. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/crossref.py +0 -0
  20. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/__init__.py +0 -0
  21. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/client.py +0 -0
  22. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/reports.py +0 -0
  23. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/ga4/tracking.py +0 -0
  24. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/__init__.py +0 -0
  25. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/audit.py +0 -0
  26. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/guards.py +0 -0
  27. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/safety/preview.py +0 -0
  28. {adloop-0.5.2 → adloop-0.6.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.5.2"
5
+ __version__ = "0.6.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -267,7 +267,8 @@ def get_negative_keyword_list_keywords(
267
267
  return {"error": "shared_set_id must be a numeric ID"}
268
268
 
269
269
  query = f"""
270
- SELECT shared_criterion.keyword.text,
270
+ SELECT shared_criterion.criterion_id,
271
+ shared_criterion.keyword.text,
271
272
  shared_criterion.keyword.match_type,
272
273
  shared_criterion.type,
273
274
  shared_set.id, shared_set.name
@@ -277,6 +278,11 @@ def get_negative_keyword_list_keywords(
277
278
  """
278
279
 
279
280
  rows = execute_query(config, customer_id, query)
281
+ for row in rows:
282
+ ssid = row.get("shared_set.id")
283
+ crit_id = row.get("shared_criterion.criterion_id")
284
+ if ssid and crit_id:
285
+ row["resource_id"] = f"{ssid}~{crit_id}"
280
286
  return {
281
287
  "keywords": rows,
282
288
  "total_keywords": len(rows),
@@ -396,6 +396,77 @@ def propose_negative_keyword_list(
396
396
  return plan.to_preview()
397
397
 
398
398
 
399
+ def add_to_negative_keyword_list(
400
+ config: AdLoopConfig,
401
+ *,
402
+ customer_id: str = "",
403
+ shared_set_id: str = "",
404
+ keywords: list[str] | None = None,
405
+ match_type: str = "EXACT",
406
+ ) -> dict:
407
+ """Draft adding keywords to an existing shared negative keyword list — returns PREVIEW.
408
+
409
+ Unlike ``propose_negative_keyword_list`` (which creates a NEW list), this
410
+ appends keywords to an existing SharedSet identified by ``shared_set_id``.
411
+ Use ``get_negative_keyword_lists`` to find the list's ID. Call
412
+ ``confirm_and_apply`` with the returned plan_id to execute.
413
+ """
414
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
415
+ from adloop.safety.preview import ChangePlan, store_plan
416
+
417
+ try:
418
+ check_blocked_operation("add_to_negative_keyword_list", config.safety)
419
+ except SafetyViolation as e:
420
+ return {"error": str(e)}
421
+
422
+ keywords = keywords or []
423
+ match_type = match_type.upper()
424
+
425
+ errors = []
426
+ if not shared_set_id:
427
+ errors.append("shared_set_id is required")
428
+ elif not str(shared_set_id).isdigit():
429
+ errors.append("shared_set_id must be a numeric ID (from get_negative_keyword_lists)")
430
+ if not keywords:
431
+ errors.append("At least one keyword is required")
432
+ if match_type not in _VALID_MATCH_TYPES:
433
+ errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
434
+ if errors:
435
+ return {"error": "Validation failed", "details": errors}
436
+
437
+ seen: set[str] = set()
438
+ deduped: list[str] = []
439
+ for kw in keywords:
440
+ text = kw.strip()
441
+ if not text:
442
+ continue
443
+ key = text.lower()
444
+ if key in seen:
445
+ continue
446
+ seen.add(key)
447
+ deduped.append(text)
448
+
449
+ if not deduped:
450
+ return {
451
+ "error": "Validation failed",
452
+ "details": ["At least one non-empty keyword is required"],
453
+ }
454
+
455
+ plan = ChangePlan(
456
+ operation="add_to_negative_keyword_list",
457
+ entity_type="negative_keyword_list",
458
+ entity_id=str(shared_set_id),
459
+ customer_id=customer_id,
460
+ changes={
461
+ "shared_set_id": str(shared_set_id),
462
+ "keywords": deduped,
463
+ "match_type": match_type,
464
+ },
465
+ )
466
+ store_plan(plan)
467
+ return plan.to_preview()
468
+
469
+
399
470
  def update_ad_group(
400
471
  config: AdLoopConfig,
401
472
  *,
@@ -484,11 +555,26 @@ def remove_entity(
484
555
  entity_type: str = "",
485
556
  entity_id: str = "",
486
557
  ) -> dict:
487
- """Draft removing an entity (keyword, negative_keyword, ad, ad_group, campaign).
558
+ """Draft removing an entity returns preview.
559
+
560
+ Supported ``entity_type`` values: ``campaign``, ``ad_group``, ``ad``,
561
+ ``keyword``, ``negative_keyword``, ``shared_criterion``, ``campaign_asset``,
562
+ ``asset``, ``customer_asset``.
563
+
564
+ Composite ``entity_id`` formats:
565
+
566
+ - ``keyword``: ``adGroupId~criterionId``
567
+ - ``negative_keyword``: ``campaignId~criterionId`` (use the ``resource_id``
568
+ field from ``get_negative_keywords``)
569
+ - ``shared_criterion``: ``sharedSetId~criterionId`` (use the ``resource_id``
570
+ field from ``get_negative_keyword_list_keywords``)
571
+ - ``campaign_asset``: ``campaignId~assetId~fieldType``
572
+ - ``customer_asset``: ``assetId~fieldType``
573
+ - ``asset``: bare asset ID
488
574
 
489
575
  This is a DESTRUCTIVE operation — removed entities cannot be re-enabled.
490
- For keywords and negative keywords, this fully deletes the criterion.
491
- Returns a preview; call confirm_and_apply to execute.
576
+ Prefer ``pause_entity`` unless the user explicitly wants permanent removal.
577
+ Call ``confirm_and_apply`` with the returned plan_id to execute.
492
578
  """
493
579
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
494
580
  from adloop.safety.preview import ChangePlan, store_plan
@@ -1167,6 +1253,7 @@ _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
1167
1253
  _VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"}
1168
1254
  _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {
1169
1255
  "negative_keyword", "campaign_asset", "asset", "customer_asset",
1256
+ "shared_criterion",
1170
1257
  }
1171
1258
 
1172
1259
  _SMART_BIDDING_STRATEGIES = {
@@ -1726,6 +1813,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
1726
1813
  "add_keywords": _apply_add_keywords,
1727
1814
  "add_negative_keywords": _apply_add_negative_keywords,
1728
1815
  "create_negative_keyword_list": _apply_create_negative_keyword_list,
1816
+ "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list,
1729
1817
  "pause_entity": _apply_status_change,
1730
1818
  "enable_entity": _apply_status_change,
1731
1819
  "remove_entity": _apply_remove,
@@ -2282,6 +2370,19 @@ def _apply_remove(
2282
2370
  customer_id=cid, operations=[operation]
2283
2371
  )
2284
2372
 
2373
+ elif entity_type == "shared_criterion":
2374
+ if "~" not in entity_id:
2375
+ raise ValueError(
2376
+ f"shared_criterion entity_id must be "
2377
+ f"'sharedSetId~criterionId', got '{entity_id}'"
2378
+ )
2379
+ service = client.get_service("SharedCriterionService")
2380
+ operation = client.get_type("SharedCriterionOperation")
2381
+ operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}"
2382
+ response = service.mutate_shared_criteria(
2383
+ customer_id=cid, operations=[operation]
2384
+ )
2385
+
2285
2386
  elif entity_type == "campaign_asset":
2286
2387
  parts = entity_id.split("~")
2287
2388
  if len(parts) != 3:
@@ -2599,3 +2700,34 @@ def _apply_create_negative_keyword_list(
2599
2700
  "campaign_shared_set_resource": css_response.results[0].resource_name,
2600
2701
  "keyword_count": len(changes["keywords"]),
2601
2702
  }
2703
+
2704
+
2705
+ def _apply_add_to_negative_keyword_list(
2706
+ client: object, cid: str, changes: dict
2707
+ ) -> dict:
2708
+ """Append keywords to an existing shared negative keyword list."""
2709
+ shared_set_service = client.get_service("SharedSetService")
2710
+ shared_set_resource = shared_set_service.shared_set_path(
2711
+ cid, changes["shared_set_id"]
2712
+ )
2713
+
2714
+ sc_service = client.get_service("SharedCriterionService")
2715
+ operations = []
2716
+ for kw_text in changes["keywords"]:
2717
+ op = client.get_type("SharedCriterionOperation")
2718
+ criterion = op.create
2719
+ criterion.shared_set = shared_set_resource
2720
+ criterion.keyword.text = kw_text
2721
+ criterion.keyword.match_type = getattr(
2722
+ client.enums.KeywordMatchTypeEnum, changes["match_type"]
2723
+ )
2724
+ operations.append(op)
2725
+
2726
+ response = sc_service.mutate_shared_criteria(
2727
+ customer_id=cid, operations=operations
2728
+ )
2729
+ return {
2730
+ "shared_set_resource": shared_set_resource,
2731
+ "resource_names": [r.resource_name for r in response.results],
2732
+ "keyword_count": len(response.results),
2733
+ }
@@ -979,6 +979,39 @@ def propose_negative_keyword_list(
979
979
  )
980
980
 
981
981
 
982
+ @mcp.tool(annotations=_WRITE)
983
+ @_safe
984
+ def add_to_negative_keyword_list(
985
+ shared_set_id: str,
986
+ keywords: list[str],
987
+ customer_id: str = "",
988
+ match_type: str = "EXACT",
989
+ ) -> dict:
990
+ """Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW.
991
+
992
+ Use this when a suitable list already exists and only needs more keywords
993
+ (instead of propose_negative_keyword_list, which creates a new list).
994
+ Always call get_negative_keyword_lists first to find the right shared_set_id
995
+ and get_negative_keyword_list_keywords to avoid duplicating existing terms.
996
+
997
+ shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
998
+ keywords: list of keyword strings to append (duplicates in the input list
999
+ are collapsed).
1000
+ match_type: "EXACT", "PHRASE", or "BROAD"
1001
+
1002
+ Call confirm_and_apply with the returned plan_id to execute.
1003
+ """
1004
+ from adloop.ads.write import add_to_negative_keyword_list as _impl
1005
+
1006
+ return _impl(
1007
+ _config,
1008
+ customer_id=customer_id or _config.ads.customer_id,
1009
+ shared_set_id=shared_set_id,
1010
+ keywords=keywords,
1011
+ match_type=match_type,
1012
+ )
1013
+
1014
+
982
1015
  @mcp.tool(annotations=_WRITE)
983
1016
  @_safe
984
1017
  def update_ad_group(
@@ -1119,11 +1152,13 @@ def remove_entity(
1119
1152
  """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE.
1120
1153
 
1121
1154
  entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword",
1122
- "campaign_asset", "asset", or "customer_asset"
1155
+ "shared_criterion", "campaign_asset", "asset", or "customer_asset"
1123
1156
  entity_id: The resource ID.
1124
1157
  For keywords: "adGroupId~criterionId"
1125
1158
  For negative_keywords: "campaignId~criterionId"
1126
1159
  (use the resource_id field from get_negative_keywords)
1160
+ For shared_criterion: "sharedSetId~criterionId"
1161
+ (use the resource_id field from get_negative_keyword_list_keywords)
1127
1162
  For campaign_asset: "campaignId~assetId~fieldType"
1128
1163
  For asset: simple asset ID
1129
1164
  For customer_asset: "assetId~fieldType"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes