gadschain 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SnipMCP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: gadschain
3
+ Version: 0.1.0
4
+ Summary: Google Ads MCP server — clean, agent-friendly tools over the Google Ads API.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/SnipMCP/gadschain
7
+ Project-URL: Repository, https://github.com/SnipMCP/gadschain
8
+ Project-URL: Issues, https://github.com/SnipMCP/gadschain/issues
9
+ Keywords: mcp,google-ads,ads,marketing,ai-agents
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: fastmcp>=0.1.0
24
+ Requires-Dist: google-ads<32.0.0,>=31.0.0
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Requires-Dist: python-dotenv>=1.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # GadsChain
33
+
34
+ The AI layer between your Google Ads account and your marketing decisions.
35
+
36
+ **Battle-tested.** Six tools cover the daily-ops loop — campaign listing, search-term review, budget tuning, pause/enable, and negative-keyword grooming. All responses are strict Pydantic models. No raw protobuf reaches the agent.
37
+
38
+ ## ☁️ Moving to production?
39
+
40
+ The open-source server runs locally with your own API keys.
41
+ For hosted infrastructure with multi-account failover, SLA guarantees,
42
+ and webhook alerts — [join the managed cloud waitlist](https://snipmcp.com).
43
+
44
+ ## The Problem
45
+
46
+ Raw Google Ads API returns thousands of rows. One bad campaign structure bleeds budget silently. GadsChain reads, sanitizes, and acts on your ad data before waste compounds.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ git clone https://github.com/SnipMCP/gadschain.git
52
+ cd gadschain
53
+ pip install -e ".[dev]"
54
+ cp .env.example .env
55
+ ```
56
+
57
+ Or with Docker:
58
+
59
+ ```bash
60
+ docker-compose up --build
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ ```env
66
+ GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token_here
67
+ GOOGLE_ADS_CLIENT_ID=your_oauth_client_id_here
68
+ GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret_here
69
+ GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token_here
70
+ GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # MCC (manager), digits only
71
+ GOOGLE_ADS_CUSTOMER_ID=1234567890 # default operating account
72
+ GOOGLE_ADS_API_VERSION=v24
73
+ LOG_LEVEL=INFO
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ Three example prompts to send to Claude (or any MCP-compatible agent):
79
+
80
+ 1. `Use get_campaigns to show me which campaigns are bleeding budget this month`
81
+ 2. `Run get_search_terms for the last 30 days and tell me which queries are wasting spend`
82
+ 3. `Add "free", "cheap", "jobs" as negative keywords to campaign 12345`
83
+
84
+ ### Run it in two terminals
85
+
86
+ ```bash
87
+ # Tab 1 — start the MCP server
88
+ python -m gadschain.server
89
+ ```
90
+
91
+ ```bash
92
+ # Tab 2 — call a tool from a Python shell or your MCP client
93
+ # Tool signatures:
94
+ # get_campaigns(customer_id=None)
95
+ # get_search_terms(customer_id=None, days=30, campaign_id=None)
96
+ # update_budget(campaign_id, new_budget_dollars, customer_id=None)
97
+ # pause_campaign(campaign_id, customer_id=None)
98
+ # enable_campaign(campaign_id, customer_id=None)
99
+ # add_negative_keywords(campaign_id, keywords, match_type="BROAD", customer_id=None)
100
+ ```
101
+
102
+ ## How it works
103
+
104
+ Three layers between raw Google Ads output and your model:
105
+
106
+ ```
107
+ Google Ads API → [Fetch] → [Transform] → [Act] → MCP Tool → AI Agent
108
+ GAQL micros→$ safe
109
+ queries enum→str mutations
110
+ CTR→% shared-budget guard
111
+ ```
112
+
113
+ - **Fetch**: Targeted GAQL queries — only the columns the daily-ops loop actually needs. No `SELECT *`, no protobuf pagination footguns.
114
+ - **Transform**: Currency micros divided to dollars, CTR scaled to percent, enums to human strings, every nested attribute lookup tolerates missing fields without crashing.
115
+ - **Act**: Mutations route through guard rails — `REMOVED` blocked on status changes, shared budgets refused (`shared_budget_refused`), match types validated before any mutate call. The agent never gets an exception; it gets a structured `{"error": ..., "message": ...}` it can reason about.
116
+
117
+ ### Real numbers from a live Franka Pizzeria account (28-day window)
118
+
119
+ ```
120
+ RAW GOOGLE ADS PAYLOAD GADSCHAIN OUTPUT
121
+ ─────────────────────────────────────────────────
122
+ Impressions: 3,389 Spend (28d): $51.41
123
+ Clicks: 163 Conversions: 3 ($17.14 each)
124
+ CTR: 4.81% Conv. rate: 1.84%
125
+ Cost/click: $0.32 avg Surface: Display Network waste
126
+ identified on Fridays
127
+ ($0.11 CPC vs $0.44 avg)
128
+ ```
129
+
130
+ In one read of a real account, GadsChain surfaced **$51.41 spent over 28 days for 3 conversions at $17.14 each** — a 1.84% conversion rate hidden inside a 4.81% CTR that looks healthy on paper. The Display Network was the silent culprit, with Friday clicks averaging **$0.11 CPC vs the $0.44 search-side average** — cheap junk traffic inflating CTR while contributing nothing to conversions. The agent saw it because the transformed payload made channel attribution legible instead of buried in protobuf.
131
+
132
+ ## Roadmap
133
+
134
+ - Managed cloud tier (hosted, multi-tenant, webhook alerts)
135
+ - Phase 2: ChatGPT REST shim (FastAPI surface over the same six tools)
136
+ - Bid-strategy tuning tools (target CPA, target ROAS)
137
+ - Anomaly alerts on cost-per-conversion drift
138
+
139
+ ## Contributing
140
+
141
+ PRs welcome. Run `pytest` before submitting.
@@ -0,0 +1,110 @@
1
+ # GadsChain
2
+
3
+ The AI layer between your Google Ads account and your marketing decisions.
4
+
5
+ **Battle-tested.** Six tools cover the daily-ops loop — campaign listing, search-term review, budget tuning, pause/enable, and negative-keyword grooming. All responses are strict Pydantic models. No raw protobuf reaches the agent.
6
+
7
+ ## ☁️ Moving to production?
8
+
9
+ The open-source server runs locally with your own API keys.
10
+ For hosted infrastructure with multi-account failover, SLA guarantees,
11
+ and webhook alerts — [join the managed cloud waitlist](https://snipmcp.com).
12
+
13
+ ## The Problem
14
+
15
+ Raw Google Ads API returns thousands of rows. One bad campaign structure bleeds budget silently. GadsChain reads, sanitizes, and acts on your ad data before waste compounds.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ git clone https://github.com/SnipMCP/gadschain.git
21
+ cd gadschain
22
+ pip install -e ".[dev]"
23
+ cp .env.example .env
24
+ ```
25
+
26
+ Or with Docker:
27
+
28
+ ```bash
29
+ docker-compose up --build
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ ```env
35
+ GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token_here
36
+ GOOGLE_ADS_CLIENT_ID=your_oauth_client_id_here
37
+ GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret_here
38
+ GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token_here
39
+ GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # MCC (manager), digits only
40
+ GOOGLE_ADS_CUSTOMER_ID=1234567890 # default operating account
41
+ GOOGLE_ADS_API_VERSION=v24
42
+ LOG_LEVEL=INFO
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Three example prompts to send to Claude (or any MCP-compatible agent):
48
+
49
+ 1. `Use get_campaigns to show me which campaigns are bleeding budget this month`
50
+ 2. `Run get_search_terms for the last 30 days and tell me which queries are wasting spend`
51
+ 3. `Add "free", "cheap", "jobs" as negative keywords to campaign 12345`
52
+
53
+ ### Run it in two terminals
54
+
55
+ ```bash
56
+ # Tab 1 — start the MCP server
57
+ python -m gadschain.server
58
+ ```
59
+
60
+ ```bash
61
+ # Tab 2 — call a tool from a Python shell or your MCP client
62
+ # Tool signatures:
63
+ # get_campaigns(customer_id=None)
64
+ # get_search_terms(customer_id=None, days=30, campaign_id=None)
65
+ # update_budget(campaign_id, new_budget_dollars, customer_id=None)
66
+ # pause_campaign(campaign_id, customer_id=None)
67
+ # enable_campaign(campaign_id, customer_id=None)
68
+ # add_negative_keywords(campaign_id, keywords, match_type="BROAD", customer_id=None)
69
+ ```
70
+
71
+ ## How it works
72
+
73
+ Three layers between raw Google Ads output and your model:
74
+
75
+ ```
76
+ Google Ads API → [Fetch] → [Transform] → [Act] → MCP Tool → AI Agent
77
+ GAQL micros→$ safe
78
+ queries enum→str mutations
79
+ CTR→% shared-budget guard
80
+ ```
81
+
82
+ - **Fetch**: Targeted GAQL queries — only the columns the daily-ops loop actually needs. No `SELECT *`, no protobuf pagination footguns.
83
+ - **Transform**: Currency micros divided to dollars, CTR scaled to percent, enums to human strings, every nested attribute lookup tolerates missing fields without crashing.
84
+ - **Act**: Mutations route through guard rails — `REMOVED` blocked on status changes, shared budgets refused (`shared_budget_refused`), match types validated before any mutate call. The agent never gets an exception; it gets a structured `{"error": ..., "message": ...}` it can reason about.
85
+
86
+ ### Real numbers from a live Franka Pizzeria account (28-day window)
87
+
88
+ ```
89
+ RAW GOOGLE ADS PAYLOAD GADSCHAIN OUTPUT
90
+ ─────────────────────────────────────────────────
91
+ Impressions: 3,389 Spend (28d): $51.41
92
+ Clicks: 163 Conversions: 3 ($17.14 each)
93
+ CTR: 4.81% Conv. rate: 1.84%
94
+ Cost/click: $0.32 avg Surface: Display Network waste
95
+ identified on Fridays
96
+ ($0.11 CPC vs $0.44 avg)
97
+ ```
98
+
99
+ In one read of a real account, GadsChain surfaced **$51.41 spent over 28 days for 3 conversions at $17.14 each** — a 1.84% conversion rate hidden inside a 4.81% CTR that looks healthy on paper. The Display Network was the silent culprit, with Friday clicks averaging **$0.11 CPC vs the $0.44 search-side average** — cheap junk traffic inflating CTR while contributing nothing to conversions. The agent saw it because the transformed payload made channel attribution legible instead of buried in protobuf.
100
+
101
+ ## Roadmap
102
+
103
+ - Managed cloud tier (hosted, multi-tenant, webhook alerts)
104
+ - Phase 2: ChatGPT REST shim (FastAPI surface over the same six tools)
105
+ - Bid-strategy tuning tools (target CPA, target ROAS)
106
+ - Anomaly alerts on cost-per-conversion drift
107
+
108
+ ## Contributing
109
+
110
+ PRs welcome. Run `pytest` before submitting.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gadschain"
7
+ version = "0.1.0"
8
+ description = "Google Ads MCP server — clean, agent-friendly tools over the Google Ads API."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.11"
12
+ keywords = ["mcp", "google-ads", "ads", "marketing", "ai-agents"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Office/Business",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "fastmcp>=0.1.0",
27
+ "google-ads>=31.0.0,<32.0.0",
28
+ "pydantic>=2.0.0",
29
+ "python-dotenv>=1.0.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ "pytest-asyncio>=0.23.0",
36
+ ]
37
+
38
+ [project.scripts]
39
+ gadschain = "gadschain.server:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/SnipMCP/gadschain"
43
+ Repository = "https://github.com/SnipMCP/gadschain"
44
+ Issues = "https://github.com/SnipMCP/gadschain/issues"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .base import BaseAdsClient, AdsClientError
2
+ from .google_ads import GoogleAdsClientWrapper
3
+
4
+ __all__ = ["BaseAdsClient", "AdsClientError", "GoogleAdsClientWrapper"]
@@ -0,0 +1,42 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+
4
+
5
+ class AdsClientError(Exception):
6
+ def __init__(self, message: str, code: Optional[str] = None):
7
+ super().__init__(message)
8
+ self.code = code
9
+
10
+
11
+ class BaseAdsClient(ABC):
12
+ name: str = "base"
13
+
14
+ @abstractmethod
15
+ def list_campaigns(self, customer_id: str) -> list[dict]:
16
+ ...
17
+
18
+ @abstractmethod
19
+ def list_search_terms(self, customer_id: str, days: int, campaign_id: Optional[str] = None) -> list[dict]:
20
+ ...
21
+
22
+ @abstractmethod
23
+ def get_campaign_budget(self, customer_id: str, campaign_id: str) -> dict:
24
+ ...
25
+
26
+ @abstractmethod
27
+ def update_campaign_budget(self, customer_id: str, campaign_id: str, daily_budget_usd: float) -> dict:
28
+ ...
29
+
30
+ @abstractmethod
31
+ def set_campaign_status(self, customer_id: str, campaign_id: str, status: str) -> dict:
32
+ ...
33
+
34
+ @abstractmethod
35
+ def add_negative_keywords(
36
+ self,
37
+ customer_id: str,
38
+ campaign_id: str,
39
+ keywords: list[str],
40
+ match_type: str = "BROAD",
41
+ ) -> dict:
42
+ ...
@@ -0,0 +1,302 @@
1
+ import os
2
+ from datetime import date, timedelta
3
+ from typing import Optional
4
+
5
+ from .base import BaseAdsClient, AdsClientError
6
+
7
+
8
+ VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
9
+ MUTABLE_STATUSES = {"ENABLED", "PAUSED"}
10
+
11
+
12
+ def _clean_customer_id(cid: str) -> str:
13
+ """Strip dashes and whitespace from a Google Ads customer ID."""
14
+ return (cid or "").replace("-", "").replace(" ", "")
15
+
16
+
17
+ def _date_range(days: int) -> tuple[str, str]:
18
+ end = date.today()
19
+ start = end - timedelta(days=max(1, int(days)))
20
+ return start.isoformat(), end.isoformat()
21
+
22
+
23
+ class GoogleAdsClientWrapper(BaseAdsClient):
24
+ """Wraps google.ads.googleads.client.GoogleAdsClient with lazy auth."""
25
+
26
+ name = "google_ads"
27
+
28
+ def __init__(
29
+ self,
30
+ developer_token: Optional[str] = None,
31
+ client_id: Optional[str] = None,
32
+ client_secret: Optional[str] = None,
33
+ refresh_token: Optional[str] = None,
34
+ login_customer_id: Optional[str] = None,
35
+ api_version: Optional[str] = None,
36
+ ):
37
+ self.developer_token = developer_token or os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", "")
38
+ self.client_id = client_id or os.getenv("GOOGLE_ADS_CLIENT_ID", "")
39
+ self.client_secret = client_secret or os.getenv("GOOGLE_ADS_CLIENT_SECRET", "")
40
+ self.refresh_token = refresh_token or os.getenv("GOOGLE_ADS_REFRESH_TOKEN", "")
41
+ self.login_customer_id = _clean_customer_id(
42
+ login_customer_id or os.getenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "")
43
+ )
44
+ self.api_version = api_version or os.getenv("GOOGLE_ADS_API_VERSION") or None
45
+ self._client = None
46
+
47
+ # ── Auth ─────────────────────────────────────────────────────────────────
48
+
49
+ def _ensure_client(self):
50
+ if self._client is not None:
51
+ return self._client
52
+
53
+ missing = [
54
+ name for name, val in [
55
+ ("GOOGLE_ADS_DEVELOPER_TOKEN", self.developer_token),
56
+ ("GOOGLE_ADS_CLIENT_ID", self.client_id),
57
+ ("GOOGLE_ADS_CLIENT_SECRET", self.client_secret),
58
+ ("GOOGLE_ADS_REFRESH_TOKEN", self.refresh_token),
59
+ ] if not val
60
+ ]
61
+ if missing:
62
+ raise AdsClientError(
63
+ f"Google Ads credentials missing: {', '.join(missing)}",
64
+ code="missing_credentials",
65
+ )
66
+
67
+ try:
68
+ from google.ads.googleads.client import GoogleAdsClient
69
+ except ImportError as e:
70
+ raise AdsClientError(
71
+ f"google-ads library not installed: {e}",
72
+ code="missing_dependency",
73
+ )
74
+
75
+ creds = {
76
+ "developer_token": self.developer_token,
77
+ "client_id": self.client_id,
78
+ "client_secret": self.client_secret,
79
+ "refresh_token": self.refresh_token,
80
+ "use_proto_plus": True,
81
+ }
82
+ if self.login_customer_id:
83
+ creds["login_customer_id"] = self.login_customer_id
84
+
85
+ if self.api_version:
86
+ self._client = GoogleAdsClient.load_from_dict(creds, version=self.api_version)
87
+ else:
88
+ self._client = GoogleAdsClient.load_from_dict(creds)
89
+ return self._client
90
+
91
+ # ── Read paths ───────────────────────────────────────────────────────────
92
+
93
+ def list_campaigns(self, customer_id: str) -> list[dict]:
94
+ client = self._ensure_client()
95
+ cid = _clean_customer_id(customer_id)
96
+ ga_service = client.get_service("GoogleAdsService")
97
+ query = """
98
+ SELECT
99
+ campaign.id,
100
+ campaign.name,
101
+ campaign.status,
102
+ campaign.advertising_channel_type,
103
+ campaign.bidding_strategy_type,
104
+ campaign.start_date,
105
+ campaign.end_date,
106
+ campaign_budget.amount_micros,
107
+ metrics.clicks,
108
+ metrics.impressions,
109
+ metrics.ctr,
110
+ metrics.average_cpc,
111
+ metrics.cost_micros,
112
+ metrics.conversions,
113
+ metrics.cost_per_conversion
114
+ FROM campaign
115
+ WHERE campaign.status != 'REMOVED'
116
+ ORDER BY metrics.cost_micros DESC
117
+ """
118
+ stream = ga_service.search(customer_id=cid, query=query)
119
+ return list(stream)
120
+
121
+ def list_search_terms(
122
+ self,
123
+ customer_id: str,
124
+ days: int = 30,
125
+ campaign_id: Optional[str] = None,
126
+ ) -> list[dict]:
127
+ client = self._ensure_client()
128
+ cid = _clean_customer_id(customer_id)
129
+ ga_service = client.get_service("GoogleAdsService")
130
+ start, end = _date_range(days)
131
+
132
+ where_clauses = [
133
+ f"segments.date BETWEEN '{start}' AND '{end}'",
134
+ "metrics.impressions > 0",
135
+ ]
136
+ if campaign_id:
137
+ where_clauses.append(f"campaign.id = {int(campaign_id)}")
138
+
139
+ query = f"""
140
+ SELECT
141
+ search_term_view.search_term,
142
+ metrics.clicks,
143
+ metrics.impressions,
144
+ metrics.ctr,
145
+ metrics.average_cpc,
146
+ metrics.cost_micros,
147
+ metrics.conversions,
148
+ campaign.id,
149
+ campaign.name,
150
+ ad_group.id
151
+ FROM search_term_view
152
+ WHERE {' AND '.join(where_clauses)}
153
+ ORDER BY metrics.cost_micros DESC
154
+ LIMIT 50
155
+ """
156
+ stream = ga_service.search(customer_id=cid, query=query)
157
+ return list(stream)
158
+
159
+ def get_campaign_budget(self, customer_id: str, campaign_id: str) -> dict:
160
+ client = self._ensure_client()
161
+ cid = _clean_customer_id(customer_id)
162
+ ga_service = client.get_service("GoogleAdsService")
163
+ # The campaign resource carries campaign_budget as an implicit join target;
164
+ # selecting campaign_budget.* from campaign yields the linked budget row.
165
+ # TODO: confirm against live API; fallback is to query `campaign_budget` directly
166
+ # using the resource_name returned by `campaign.campaign_budget`.
167
+ query = f"""
168
+ SELECT
169
+ campaign_budget.id,
170
+ campaign_budget.amount_micros,
171
+ campaign_budget.explicitly_shared
172
+ FROM campaign
173
+ WHERE campaign.id = {int(campaign_id)}
174
+ """
175
+ rows = list(ga_service.search(customer_id=cid, query=query))
176
+ if not rows:
177
+ raise AdsClientError(
178
+ f"No budget found for campaign {campaign_id} on customer {cid}",
179
+ code="budget_not_found",
180
+ )
181
+ return rows[0]
182
+
183
+ # ── Mutate paths ─────────────────────────────────────────────────────────
184
+
185
+ def update_campaign_budget(
186
+ self,
187
+ customer_id: str,
188
+ campaign_id: str,
189
+ daily_budget_usd: float,
190
+ ) -> dict:
191
+ client = self._ensure_client()
192
+ cid = _clean_customer_id(customer_id)
193
+
194
+ existing = self.get_campaign_budget(cid, campaign_id)
195
+ budget_id = existing.campaign_budget.id
196
+ previous_micros = existing.campaign_budget.amount_micros
197
+ explicitly_shared = bool(getattr(existing.campaign_budget, "explicitly_shared", False))
198
+
199
+ if explicitly_shared:
200
+ raise AdsClientError(
201
+ f"Budget {budget_id} is explicitly shared across campaigns. Refusing to mutate.",
202
+ code="shared_budget_refused",
203
+ )
204
+
205
+ new_micros = int(round(float(daily_budget_usd) * 1_000_000))
206
+
207
+ budget_service = client.get_service("CampaignBudgetService")
208
+ operation = client.get_type("CampaignBudgetOperation")
209
+ operation.update.resource_name = budget_service.campaign_budget_path(cid, budget_id)
210
+ operation.update.amount_micros = new_micros
211
+
212
+ from google.api_core import protobuf_helpers
213
+ operation.update_mask.CopyFrom(
214
+ protobuf_helpers.field_mask(None, operation.update._pb)
215
+ )
216
+
217
+ response = budget_service.mutate_campaign_budgets(
218
+ customer_id=cid,
219
+ operations=[operation],
220
+ )
221
+ return {
222
+ "budget_id": str(budget_id),
223
+ "previous_micros": int(previous_micros) if previous_micros is not None else None,
224
+ "new_micros": new_micros,
225
+ "resource_name": response.results[0].resource_name if response.results else None,
226
+ }
227
+
228
+ def set_campaign_status(self, customer_id: str, campaign_id: str, status: str) -> dict:
229
+ status = (status or "").upper()
230
+ if status not in MUTABLE_STATUSES:
231
+ raise AdsClientError(
232
+ f"status must be one of {sorted(MUTABLE_STATUSES)}. Got '{status}'.",
233
+ code="invalid_status",
234
+ )
235
+ client = self._ensure_client()
236
+ cid = _clean_customer_id(customer_id)
237
+ campaign_service = client.get_service("CampaignService")
238
+ status_enum = client.enums.CampaignStatusEnum.CampaignStatus
239
+
240
+ operation = client.get_type("CampaignOperation")
241
+ operation.update.resource_name = campaign_service.campaign_path(cid, campaign_id)
242
+ operation.update.status = getattr(status_enum, status)
243
+
244
+ from google.api_core import protobuf_helpers
245
+ operation.update_mask.CopyFrom(
246
+ protobuf_helpers.field_mask(None, operation.update._pb)
247
+ )
248
+
249
+ response = campaign_service.mutate_campaigns(
250
+ customer_id=cid,
251
+ operations=[operation],
252
+ )
253
+ return {
254
+ "campaign_id": str(campaign_id),
255
+ "new_status": status,
256
+ "resource_name": response.results[0].resource_name if response.results else None,
257
+ }
258
+
259
+ def add_negative_keywords(
260
+ self,
261
+ customer_id: str,
262
+ campaign_id: str,
263
+ keywords: list[str],
264
+ match_type: str = "BROAD",
265
+ ) -> dict:
266
+ match_type = (match_type or "BROAD").upper()
267
+ if match_type not in VALID_MATCH_TYPES:
268
+ raise AdsClientError(
269
+ f"match_type must be one of {sorted(VALID_MATCH_TYPES)}. Got '{match_type}'.",
270
+ code="invalid_match_type",
271
+ )
272
+ keywords = [k for k in (keywords or []) if k and k.strip()]
273
+ if not keywords:
274
+ raise AdsClientError("keywords list is empty", code="empty_keywords")
275
+
276
+ client = self._ensure_client()
277
+ cid = _clean_customer_id(customer_id)
278
+ campaign_service = client.get_service("CampaignService")
279
+ criterion_service = client.get_service("CampaignCriterionService")
280
+ match_enum = client.enums.KeywordMatchTypeEnum.KeywordMatchType
281
+
282
+ operations = []
283
+ campaign_resource = campaign_service.campaign_path(cid, campaign_id)
284
+ for kw in keywords:
285
+ op = client.get_type("CampaignCriterionOperation")
286
+ op.create.campaign = campaign_resource
287
+ op.create.negative = True
288
+ op.create.keyword.text = kw.strip()
289
+ op.create.keyword.match_type = getattr(match_enum, match_type)
290
+ operations.append(op)
291
+
292
+ response = criterion_service.mutate_campaign_criteria(
293
+ customer_id=cid,
294
+ operations=operations,
295
+ )
296
+ return {
297
+ "campaign_id": str(campaign_id),
298
+ "match_type": match_type,
299
+ "keywords_added": keywords,
300
+ "count": len(response.results),
301
+ "resource_names": [r.resource_name for r in response.results],
302
+ }
@@ -0,0 +1,3 @@
1
+ from .transformer import Transformer
2
+
3
+ __all__ = ["Transformer"]