adloop 0.13.0__tar.gz → 0.13.2__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 (52) hide show
  1. {adloop-0.13.0 → adloop-0.13.2}/PKG-INFO +3 -1
  2. {adloop-0.13.0 → adloop-0.13.2}/README.md +2 -0
  3. adloop-0.13.2/pyproject.toml +50 -0
  4. adloop-0.13.0/pyproject.toml → adloop-0.13.2/pyproject.toml.orig +1 -1
  5. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/__init__.py +21 -2
  6. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/write.py +72 -15
  7. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/client.py +36 -9
  8. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/read.py +6 -1
  9. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/__main__.py +0 -0
  10. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/_mcp_patches.py +0 -0
  11. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/client.py +0 -0
  13. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/currency.py +0 -0
  14. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/enums.py +0 -0
  15. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/forecast.py +0 -0
  16. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/gaql.py +0 -0
  17. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/pmax.py +0 -0
  18. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ads/read.py +0 -0
  19. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/auth.py +0 -0
  20. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/cli.py +0 -0
  21. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/config.py +0 -0
  22. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/crossref.py +0 -0
  23. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/diagnostics.py +0 -0
  24. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/__init__.py +0 -0
  25. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/client.py +0 -0
  26. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/reports.py +0 -0
  27. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/tracking.py +0 -0
  28. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/ga4/write.py +0 -0
  29. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/__init__.py +0 -0
  30. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/client.py +0 -0
  31. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gsc/reports.py +0 -0
  32. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/__init__.py +0 -0
  33. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/client.py +0 -0
  34. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/gtm/read.py +0 -0
  35. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/merchant/__init__.py +0 -0
  36. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/pagespeed.py +0 -0
  37. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/__init__.py +0 -0
  38. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/adloop.md +0 -0
  39. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/analyze-performance.md +0 -0
  40. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/budget-plan.md +0 -0
  41. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/create-ad.md +0 -0
  42. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/create-campaign.md +0 -0
  43. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  44. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  45. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/rules_install.py +0 -0
  46. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/runtime.py +0 -0
  47. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/__init__.py +0 -0
  48. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/audit.py +0 -0
  49. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/guards.py +0 -0
  50. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/safety/preview.py +0 -0
  51. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/server.py +0 -0
  52. {adloop-0.13.0 → adloop-0.13.2}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: The AI command center for Google Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -47,6 +47,8 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
47
47
  > [!TIP]
48
48
  > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
49
49
 
50
+ > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
51
+
50
52
  ---
51
53
 
52
54
  ## Cloud or Self-Hosted?
@@ -21,6 +21,8 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
21
21
  > [!TIP]
22
22
  > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
23
23
 
24
+ > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
25
+
24
26
  ---
25
27
 
26
28
  ## Cloud or Self-Hosted?
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "adloop"
3
+ version = "0.13.2"
4
+ description = "The AI command center for Google Ads, GA4, and tracking code."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ keywords = [
8
+ "mcp",
9
+ "google-ads",
10
+ "google-analytics",
11
+ "ga4",
12
+ "cursor",
13
+ "marketing",
14
+ ]
15
+ dependencies = [
16
+ "fastmcp>=3.0.0",
17
+ "google-ads>=31.1.0",
18
+ "google-analytics-data>=0.20.0",
19
+ "google-analytics-admin>=0.27.0",
20
+ "google-api-python-client>=2.100.0",
21
+ "google-auth-oauthlib>=1.0.0",
22
+ "google-api-python-client>=2.0.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "Daniel Klose"
28
+ email = "info@daniel-klose.com"
29
+
30
+ [project.license]
31
+ text = "MIT"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/kLOsk/adloop"
35
+ Repository = "https://github.com/kLOsk/adloop"
36
+ Issues = "https://github.com/kLOsk/adloop/issues"
37
+ Changelog = "https://github.com/kLOsk/adloop/releases"
38
+
39
+ [project.scripts]
40
+ adloop = "adloop:main"
41
+
42
+ [project.optional-dependencies]
43
+ dev = [
44
+ "pytest>=8.0",
45
+ "pytest-asyncio>=0.23",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.10.8,<0.11.0"]
50
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.13.0"
3
+ version = "0.13.2"
4
4
  description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,6 +9,25 @@ except PackageNotFoundError: # running from a source tree without install
9
9
  __version__ = "0.0.0.dev0"
10
10
 
11
11
 
12
+ def install_runtime_patches() -> None:
13
+ """Arm the upstream-bug workarounds for a long-running host process.
14
+
15
+ Importing ``adloop.server`` is deliberately free of process-global side
16
+ effects so the server can be embedded in an ASGI app. That leaves the
17
+ workarounds in ``_mcp_patches`` unarmed for any embedder, including a
18
+ hosted deployment, which is exactly where they matter most: the
19
+ cancellation race they guard against (python-sdk#2416) fires when a
20
+ host cancels a slow tool call, and it takes the transport down with it.
21
+
22
+ Embedders should call this once during application startup. It is
23
+ idempotent, never raises, and each patch inside self-disarms once the
24
+ upstream bug it tracks is fixed.
25
+ """
26
+ from adloop import _mcp_patches
27
+
28
+ _mcp_patches.install()
29
+
30
+
12
31
  def main() -> None:
13
32
  """Entry point for `adloop` console script.
14
33
 
@@ -50,10 +69,10 @@ def main() -> None:
50
69
  # the stdio cancellation-race monkeypatch) are deliberately installed
51
70
  # here — in the stdio entry point — rather than at adloop.server import
52
71
  # time, so embedding the server in an ASGI app stays side-effect-free.
53
- from adloop import _mcp_patches, diagnostics
72
+ from adloop import diagnostics
54
73
 
55
74
  diagnostics.install()
56
- _mcp_patches.install()
75
+ install_runtime_patches()
57
76
 
58
77
  from adloop.server import mcp
59
78
 
@@ -270,17 +270,49 @@ def _build_public_only_opener():
270
270
  return urllib.request.build_opener(_PublicOnlyRedirectHandler)
271
271
 
272
272
 
273
- def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
273
+ # Statuses that say nothing about whether the landing page is any good:
274
+ # the site is throttling us, or is briefly unwell. Refusing to draft an ad
275
+ # over one punishes the advertiser for their own rate limiting — and we
276
+ # are frequently the cause of it, since drafting several ads at once fires
277
+ # several HEAD requests at the same origin within a second or two.
278
+ #
279
+ # A genuinely dead URL still blocks: 404 and 410 are the cases this check
280
+ # exists for, and they do not resolve themselves on a retry.
281
+ _INCONCLUSIVE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
282
+
283
+
284
+ def _validate_urls(
285
+ urls: list[str], timeout: int = 10
286
+ ) -> tuple[dict[str, str | None], dict[str, str]]:
274
287
  """Check that each URL returns a 2xx/3xx status.
275
288
 
276
- Returns a dict of {url: error_message_or_None}. None means the URL is fine.
289
+ Returns (errors, warnings). ``errors`` maps url -> message for URLs
290
+ that should block the operation, None when fine. ``warnings`` maps
291
+ url -> message for checks that came back inconclusive; callers should
292
+ surface those but proceed, because the alternative is refusing to work
293
+ whenever the advertiser's own site is briefly throttling or flaky.
277
294
  """
278
295
  import urllib.request
279
296
  import urllib.error
280
297
 
281
298
  opener = _build_public_only_opener()
282
299
 
283
- results = {}
300
+ results: dict[str, str | None] = {}
301
+ warnings: dict[str, str] = {}
302
+
303
+ def _record_status(url: str, status: int) -> None:
304
+ if status in _INCONCLUSIVE_STATUSES:
305
+ results[url] = None
306
+ warnings[url] = (
307
+ f"HTTP {status} — could not verify the URL right now "
308
+ "(the site may be rate-limiting or temporarily down). "
309
+ "Proceeding without the check; confirm the page is live."
310
+ )
311
+ elif status >= 400:
312
+ results[url] = f"HTTP {status}"
313
+ else:
314
+ results[url] = None
315
+
284
316
  for url in urls:
285
317
  if not url:
286
318
  continue
@@ -292,10 +324,7 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
292
324
  req = urllib.request.Request(url, method="HEAD")
293
325
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
294
326
  resp = opener.open(req, timeout=timeout)
295
- if resp.status >= 400:
296
- results[url] = f"HTTP {resp.status}"
297
- else:
298
- results[url] = None
327
+ _record_status(url, resp.status)
299
328
  except urllib.error.HTTPError as e:
300
329
  if e.code == 405:
301
330
  # HEAD not allowed, try GET
@@ -303,18 +332,17 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
303
332
  req = urllib.request.Request(url, method="GET")
304
333
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
305
334
  resp = opener.open(req, timeout=timeout)
306
- if resp.status >= 400:
307
- results[url] = f"HTTP {resp.status}"
308
- else:
309
- results[url] = None
335
+ _record_status(url, resp.status)
336
+ except urllib.error.HTTPError as e2:
337
+ _record_status(url, e2.code)
310
338
  except Exception as e2:
311
339
  results[url] = str(e2)
312
340
  else:
313
- results[url] = f"HTTP {e.code}"
341
+ _record_status(url, e.code)
314
342
  except Exception as e:
315
343
  results[url] = str(e)
316
344
 
317
- return results
345
+ return results, warnings
318
346
 
319
347
 
320
348
  def _normalize_display_network_setting(
@@ -467,7 +495,7 @@ def draft_responsive_search_ad(
467
495
  if errors:
468
496
  return {"error": "Validation failed", "details": errors}
469
497
 
470
- url_check = _validate_urls([final_url])
498
+ url_check, url_warnings = _validate_urls([final_url])
471
499
  if url_check.get(final_url):
472
500
  return {
473
501
  "error": "URL validation failed",
@@ -478,6 +506,8 @@ def draft_responsive_search_ad(
478
506
  }
479
507
 
480
508
  warnings = []
509
+ if url_warnings.get(final_url):
510
+ warnings.append(f"final_url '{final_url}': {url_warnings[final_url]}")
481
511
  if len(headlines) < 8:
482
512
  warnings.append(
483
513
  f"Only {len(headlines)} headlines provided. Google recommends 8-15 "
@@ -1703,7 +1733,7 @@ def draft_sitelinks(
1703
1733
  return {"error": "Validation failed", "details": errors}
1704
1734
 
1705
1735
  sitelink_urls = [sl["final_url"] for sl in validated]
1706
- url_checks = _validate_urls(sitelink_urls)
1736
+ url_checks, url_warnings = _validate_urls(sitelink_urls)
1707
1737
  bad_urls = {u: err for u, err in url_checks.items() if err}
1708
1738
  if bad_urls:
1709
1739
  return {
@@ -1712,6 +1742,7 @@ def draft_sitelinks(
1712
1742
  f"'{url}' is not reachable: {err}" for url, err in bad_urls.items()
1713
1743
  ],
1714
1744
  }
1745
+ warnings.extend(f"'{url}': {msg}" for url, msg in url_warnings.items())
1715
1746
 
1716
1747
  if len(validated) < 2:
1717
1748
  warnings.append(
@@ -2291,6 +2322,32 @@ def _validate_campaign(
2291
2322
  f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, "
2292
2323
  f"got '{channel_type}'"
2293
2324
  )
2325
+
2326
+ # Performance Max has no ad groups. Every campaign needs at least one
2327
+ # asset group, and the API requires the asset group plus all of its
2328
+ # required assets in a single atomic mutate. We cannot supply the
2329
+ # images, so creating the campaign alone would leave the user with
2330
+ # something that can never serve while reporting success.
2331
+ if ct == "PERFORMANCE_MAX":
2332
+ errors.append(
2333
+ "Performance Max campaigns cannot be created yet. PMax has no ad "
2334
+ "groups: a campaign needs at least one asset group, and Google "
2335
+ "requires the asset group and all its assets (headlines, "
2336
+ "descriptions, business name, logo, and images in several aspect "
2337
+ "ratios) in one atomic request. Creating the campaign on its own "
2338
+ "would produce a campaign that can never serve. Create it in the "
2339
+ "Google Ads interface, then use AdLoop to analyse it — "
2340
+ "get_pmax_performance reports per-asset-group Ad Strength."
2341
+ )
2342
+ elif ct != "SEARCH":
2343
+ # These do produce a usable campaign, but nothing here can populate
2344
+ # them: draft_ad_group refuses anything that is not SEARCH.
2345
+ warnings.append(
2346
+ f"{ct} campaigns are created as a shell only. AdLoop can add ad "
2347
+ f"groups, ads and keywords to SEARCH campaigns; finish this one "
2348
+ f"in the Google Ads interface."
2349
+ )
2350
+
2294
2351
  if ct != "SEARCH" and search_partners_enabled:
2295
2352
  errors.append("search_partners_enabled is only supported for SEARCH campaigns")
2296
2353
  if ct != "SEARCH" and display_network_enabled:
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING
13
13
  if TYPE_CHECKING:
14
14
  from adloop.config import AdLoopConfig
15
15
 
16
- _BASE = "https://merchantapi.googleapis.com/accounts/v1"
16
+ _BASE = "https://merchantapi.googleapis.com"
17
+
18
+ # The Merchant API is split into independently versioned sub-APIs, and the
19
+ # sub-API is part of the path: accounts/v1 serves accounts and account
20
+ # issues, but product-status aggregation lives under issueresolution/v1.
21
+ # Guessing wrong yields a bare 404 with no error message, so every call
22
+ # states its sub-API and API_ACCOUNTS is only the default.
23
+ API_ACCOUNTS = "accounts/v1"
24
+ API_ISSUE_RESOLUTION = "issueresolution/v1"
17
25
 
18
26
  # Google blocks every Merchant API call from a GCP project that is not
19
27
  # registered as a developer with the merchant account (401 UNAUTHENTICATED,
@@ -32,6 +40,7 @@ def _request(
32
40
  method: str,
33
41
  path: str,
34
42
  *,
43
+ api: str = API_ACCOUNTS,
35
44
  params: dict | None = None,
36
45
  json_body: dict | None = None,
37
46
  ) -> dict:
@@ -39,10 +48,11 @@ def _request(
39
48
 
40
49
  from adloop.auth import get_merchant_credentials
41
50
 
51
+ url = f"{_BASE}/{api.strip('/')}/{path.lstrip('/')}"
42
52
  session = AuthorizedSession(get_merchant_credentials(config))
43
53
  response = session.request(
44
54
  method,
45
- f"{_BASE}/{path.lstrip('/')}",
55
+ url,
46
56
  params=params or {},
47
57
  json=json_body,
48
58
  timeout=30,
@@ -55,18 +65,35 @@ def _request(
55
65
  pass
56
66
  if _REGISTRATION_MARKER in detail:
57
67
  raise MerchantNotRegistered(detail)
58
- raise RuntimeError(f"Merchant API returned {response.status_code}: {detail}")
68
+ # A wrong sub-API prefix answers 404 with an empty message, so name
69
+ # the URL that failed rather than reporting a bare status code.
70
+ raise RuntimeError(
71
+ f"Merchant API returned {response.status_code} for {url}"
72
+ f"{f': {detail}' if detail else ''}"
73
+ )
59
74
  return response.json()
60
75
 
61
76
 
62
- def merchant_get(config: AdLoopConfig, path: str, params: dict | None = None) -> dict:
63
- """Authorized GET against the Merchant API accounts_v1 surface."""
64
- return _request(config, "GET", path, params=params)
77
+ def merchant_get(
78
+ config: AdLoopConfig,
79
+ path: str,
80
+ params: dict | None = None,
81
+ *,
82
+ api: str = API_ACCOUNTS,
83
+ ) -> dict:
84
+ """Authorized GET against a Merchant API sub-API (default: accounts)."""
85
+ return _request(config, "GET", path, api=api, params=params)
65
86
 
66
87
 
67
- def merchant_post(config: AdLoopConfig, path: str, body: dict | None = None) -> dict:
68
- """Authorized POST against the Merchant API accounts_v1 surface."""
69
- return _request(config, "POST", path, json_body=body or {})
88
+ def merchant_post(
89
+ config: AdLoopConfig,
90
+ path: str,
91
+ body: dict | None = None,
92
+ *,
93
+ api: str = API_ACCOUNTS,
94
+ ) -> dict:
95
+ """Authorized POST against a Merchant API sub-API (default: accounts)."""
96
+ return _request(config, "POST", path, api=api, json_body=body or {})
70
97
 
71
98
 
72
99
  def register_gcp(config: AdLoopConfig, account_id: str) -> dict:
@@ -118,6 +118,7 @@ def get_merchant_feed_health(
118
118
  them) with account-level issues that can suspend the whole account.
119
119
  """
120
120
  from adloop.merchant.client import (
121
+ API_ISSUE_RESOLUTION,
121
122
  MerchantNotRegistered,
122
123
  merchant_get,
123
124
  register_gcp,
@@ -132,8 +133,12 @@ def get_merchant_feed_health(
132
133
 
133
134
  def _fetch() -> tuple[dict, dict]:
134
135
  return (
136
+ # Product-status aggregation is served by the issueresolution
137
+ # sub-API, not accounts — the wrong prefix 404s.
135
138
  merchant_get(
136
- config, f"accounts/{account_id}/aggregateProductStatuses"
139
+ config,
140
+ f"accounts/{account_id}/aggregateProductStatuses",
141
+ api=API_ISSUE_RESOLUTION,
137
142
  ),
138
143
  merchant_get(config, f"accounts/{account_id}/issues"),
139
144
  )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes