adloop 0.14.0__tar.gz → 0.16.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.14.0 → adloop-0.16.0}/PKG-INFO +23 -24
- {adloop-0.14.0 → adloop-0.16.0}/README.md +21 -22
- {adloop-0.14.0 → adloop-0.16.0}/pyproject.toml +2 -2
- {adloop-0.14.0 → adloop-0.16.0}/pyproject.toml.orig +2 -2
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/client.py +7 -2
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/conversion_actions.py +1 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/enums.py +2 -4
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/forecast.py +4 -5
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/gaql.py +15 -6
- adloop-0.16.0/src/adloop/ads/validate_only.py +115 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/write.py +171 -44
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/cli.py +26 -13
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/adloop.md +3 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/server.py +34 -13
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/__main__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/auth.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/config.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/crossref.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/runtime.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.14.0 → adloop-0.16.0}/src/adloop/tracking.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.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
|
|
7
7
|
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
8
|
License: MIT
|
|
9
9
|
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
-
Requires-Dist: google-ads>=
|
|
10
|
+
Requires-Dist: google-ads>=32.0.0
|
|
11
11
|
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
12
|
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
13
|
Requires-Dist: google-api-python-client>=2.100.0
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
[](LICENSE)
|
|
38
38
|
[](https://www.python.org/downloads/)
|
|
39
39
|
[](https://modelcontextprotocol.io)
|
|
40
|
-
[](https://developers.google.com/google-ads/api/docs/start)
|
|
41
41
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
42
42
|
[](https://github.com/kLOsk/adloop)
|
|
43
43
|
|
|
@@ -48,7 +48,7 @@ An MCP server that gives your AI assistant read + write access to Google Ads, Re
|
|
|
48
48
|
</div>
|
|
49
49
|
|
|
50
50
|
> [!TIP]
|
|
51
|
-
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no
|
|
51
|
+
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no API access application, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
52
52
|
|
|
53
53
|
> 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
|
|
54
54
|
|
|
@@ -62,7 +62,7 @@ Both versions run the same tools with the same safety model. The difference is w
|
|
|
62
62
|
|---|---|---|
|
|
63
63
|
| **Setup** | Connect Google in two clicks | ~5 min: own Google Cloud project + `adloop init` |
|
|
64
64
|
| **Google Cloud project** | Not needed | Required (free) |
|
|
65
|
-
| **Ads
|
|
65
|
+
| **Ads API access** | Not needed | Granted to your Cloud project (no developer token, no MCC since Sept 2026) |
|
|
66
66
|
| **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
|
|
67
67
|
| **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
|
|
68
68
|
| **Updates** | Automatic | `pip install -U adloop` |
|
|
@@ -299,7 +299,7 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
299
299
|
|
|
300
300
|
> **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
|
|
301
301
|
>
|
|
302
|
-
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no
|
|
302
|
+
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no API access application, EU-hosted.
|
|
303
303
|
>
|
|
304
304
|
> *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
|
|
305
305
|
|
|
@@ -329,8 +329,8 @@ uv run adloop init
|
|
|
329
329
|
The wizard walks you through:
|
|
330
330
|
|
|
331
331
|
1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
|
|
332
|
-
2. **
|
|
333
|
-
3. **MCC Account ID** —
|
|
332
|
+
2. **Google Ads API access** — applied for on your Cloud project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview); the legacy developer-token prompt can be left empty
|
|
333
|
+
3. **MCC Account ID** — optional, only if you reach several accounts through a Manager Account
|
|
334
334
|
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
335
335
|
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
336
336
|
6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
|
|
@@ -341,26 +341,25 @@ The wizard walks you through:
|
|
|
341
341
|
### Requirements
|
|
342
342
|
|
|
343
343
|
- Python 3.11+
|
|
344
|
-
- A Google Ads account
|
|
345
|
-
-
|
|
344
|
+
- A Google Ads account (a Manager Account is optional; you only need one to reach several accounts through one login)
|
|
345
|
+
- Google Ads API access on your Google Cloud project (see below)
|
|
346
346
|
|
|
347
|
-
### Google Ads
|
|
347
|
+
### Google Ads API access
|
|
348
348
|
|
|
349
|
-
|
|
349
|
+
Since 9 September 2026, Google Ads API access belongs to the **Google Cloud project that owns your OAuth client**. Developer tokens are sunset: the API ignores the header, the wizard's token prompt is optional, and you no longer need a Manager Account to get access.
|
|
350
350
|
|
|
351
|
-
|
|
352
|
-
2. In the MCC, go to **Tools & Settings → API Center**
|
|
353
|
-
3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
|
|
351
|
+
Apply on your project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview) in the Cloud Console:
|
|
354
352
|
|
|
355
|
-
|
|
353
|
+
| Level | How to get it | What it allows |
|
|
354
|
+
|-------|---------------|----------------|
|
|
355
|
+
| **Test** | Automatic when you enable the Google Ads API | Test accounts only, **not production accounts**. `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION` (or `DEVELOPER_TOKEN_NOT_APPROVED` on older API versions) means you are here. |
|
|
356
|
+
| **Explorer** | "Upgrade access level" → apply; usually granted automatically | 2,880 operations/day on production accounts. Enough to get started. |
|
|
357
|
+
| **Basic** | Apply; reviewed automatically within minutes once the project is brand-verified (OAuth consent screen set to External and published "In production") | 15,000 operations/day. |
|
|
358
|
+
| **Standard** | Apply; manual review, about ten business days | Higher limits; Google's RMF policy applies |
|
|
356
359
|
|
|
357
|
-
|
|
358
|
-
|-------|-----------|----------------|
|
|
359
|
-
| **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
|
|
360
|
-
| **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
|
|
361
|
-
| **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
|
|
360
|
+
Projects on a Cloud free trial or with billing disabled are refused Explorer and Basic; use a project with billing set up.
|
|
362
361
|
|
|
363
|
-
> **Getting `
|
|
362
|
+
> **Getting `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION`?** Your project is at Test level. Open the Overview page above and apply for Explorer access.
|
|
364
363
|
|
|
365
364
|
### Headless Servers
|
|
366
365
|
|
|
@@ -465,7 +464,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
465
464
|
| `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
|
|
466
465
|
| `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
|
|
467
466
|
| `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
|
|
468
|
-
| `ads` | `developer_token` | — |
|
|
467
|
+
| `ads` | `developer_token` | — | Legacy, optional: API access belongs to your Cloud project since Sept 2026 |
|
|
469
468
|
| `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
|
|
470
469
|
| `ads` | `login_customer_id` | — | Your MCC account ID |
|
|
471
470
|
| `reddit` | `client_id` / `client_secret` | *(empty)* | Your Reddit developer app (Business Manager → Developer Application) |
|
|
@@ -551,7 +550,7 @@ What's been shipped and what's next:
|
|
|
551
550
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
552
551
|
- **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
|
|
553
552
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
554
|
-
- ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no
|
|
553
|
+
- ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no API access application, connect Google in two clicks (EU-hosted, GDPR-first)
|
|
555
554
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
556
555
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
557
556
|
- ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://www.python.org/downloads/)
|
|
12
12
|
[](https://modelcontextprotocol.io)
|
|
13
|
-
[](https://developers.google.com/google-ads/api/docs/start)
|
|
14
14
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
15
15
|
[](https://github.com/kLOsk/adloop)
|
|
16
16
|
|
|
@@ -21,7 +21,7 @@ An MCP server that gives your AI assistant read + write access to Google Ads, Re
|
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
23
|
> [!TIP]
|
|
24
|
-
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no
|
|
24
|
+
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no API access application, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
25
25
|
|
|
26
26
|
> 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
|
|
27
27
|
|
|
@@ -35,7 +35,7 @@ Both versions run the same tools with the same safety model. The difference is w
|
|
|
35
35
|
|---|---|---|
|
|
36
36
|
| **Setup** | Connect Google in two clicks | ~5 min: own Google Cloud project + `adloop init` |
|
|
37
37
|
| **Google Cloud project** | Not needed | Required (free) |
|
|
38
|
-
| **Ads
|
|
38
|
+
| **Ads API access** | Not needed | Granted to your Cloud project (no developer token, no MCC since Sept 2026) |
|
|
39
39
|
| **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
|
|
40
40
|
| **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
|
|
41
41
|
| **Updates** | Automatic | `pip install -U adloop` |
|
|
@@ -272,7 +272,7 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
272
272
|
|
|
273
273
|
> **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
|
|
274
274
|
>
|
|
275
|
-
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no
|
|
275
|
+
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no API access application, EU-hosted.
|
|
276
276
|
>
|
|
277
277
|
> *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
|
|
278
278
|
|
|
@@ -302,8 +302,8 @@ uv run adloop init
|
|
|
302
302
|
The wizard walks you through:
|
|
303
303
|
|
|
304
304
|
1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
|
|
305
|
-
2. **
|
|
306
|
-
3. **MCC Account ID** —
|
|
305
|
+
2. **Google Ads API access** — applied for on your Cloud project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview); the legacy developer-token prompt can be left empty
|
|
306
|
+
3. **MCC Account ID** — optional, only if you reach several accounts through a Manager Account
|
|
307
307
|
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
308
308
|
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
309
309
|
6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
|
|
@@ -314,26 +314,25 @@ The wizard walks you through:
|
|
|
314
314
|
### Requirements
|
|
315
315
|
|
|
316
316
|
- Python 3.11+
|
|
317
|
-
- A Google Ads account
|
|
318
|
-
-
|
|
317
|
+
- A Google Ads account (a Manager Account is optional; you only need one to reach several accounts through one login)
|
|
318
|
+
- Google Ads API access on your Google Cloud project (see below)
|
|
319
319
|
|
|
320
|
-
### Google Ads
|
|
320
|
+
### Google Ads API access
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
Since 9 September 2026, Google Ads API access belongs to the **Google Cloud project that owns your OAuth client**. Developer tokens are sunset: the API ignores the header, the wizard's token prompt is optional, and you no longer need a Manager Account to get access.
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
2. In the MCC, go to **Tools & Settings → API Center**
|
|
326
|
-
3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
|
|
324
|
+
Apply on your project's [Google Ads API Overview page](https://console.cloud.google.com/google/ads-apis/overview) in the Cloud Console:
|
|
327
325
|
|
|
328
|
-
|
|
326
|
+
| Level | How to get it | What it allows |
|
|
327
|
+
|-------|---------------|----------------|
|
|
328
|
+
| **Test** | Automatic when you enable the Google Ads API | Test accounts only, **not production accounts**. `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION` (or `DEVELOPER_TOKEN_NOT_APPROVED` on older API versions) means you are here. |
|
|
329
|
+
| **Explorer** | "Upgrade access level" → apply; usually granted automatically | 2,880 operations/day on production accounts. Enough to get started. |
|
|
330
|
+
| **Basic** | Apply; reviewed automatically within minutes once the project is brand-verified (OAuth consent screen set to External and published "In production") | 15,000 operations/day. |
|
|
331
|
+
| **Standard** | Apply; manual review, about ten business days | Higher limits; Google's RMF policy applies |
|
|
329
332
|
|
|
330
|
-
|
|
331
|
-
|-------|-----------|----------------|
|
|
332
|
-
| **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
|
|
333
|
-
| **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
|
|
334
|
-
| **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
|
|
333
|
+
Projects on a Cloud free trial or with billing disabled are refused Explorer and Basic; use a project with billing set up.
|
|
335
334
|
|
|
336
|
-
> **Getting `
|
|
335
|
+
> **Getting `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION`?** Your project is at Test level. Open the Overview page above and apply for Explorer access.
|
|
337
336
|
|
|
338
337
|
### Headless Servers
|
|
339
338
|
|
|
@@ -438,7 +437,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
438
437
|
| `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
|
|
439
438
|
| `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
|
|
440
439
|
| `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
|
|
441
|
-
| `ads` | `developer_token` | — |
|
|
440
|
+
| `ads` | `developer_token` | — | Legacy, optional: API access belongs to your Cloud project since Sept 2026 |
|
|
442
441
|
| `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
|
|
443
442
|
| `ads` | `login_customer_id` | — | Your MCC account ID |
|
|
444
443
|
| `reddit` | `client_id` / `client_secret` | *(empty)* | Your Reddit developer app (Business Manager → Developer Application) |
|
|
@@ -524,7 +523,7 @@ What's been shipped and what's next:
|
|
|
524
523
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
525
524
|
- **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
|
|
526
525
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
527
|
-
- ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no
|
|
526
|
+
- ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no API access application, connect Google in two clicks (EU-hosted, GDPR-first)
|
|
528
527
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
529
528
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
530
529
|
- ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.16.0"
|
|
4
4
|
description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -15,7 +15,7 @@ keywords = [
|
|
|
15
15
|
]
|
|
16
16
|
dependencies = [
|
|
17
17
|
"fastmcp>=3.0.0",
|
|
18
|
-
"google-ads>=
|
|
18
|
+
"google-ads>=32.0.0",
|
|
19
19
|
"google-analytics-data>=0.20.0",
|
|
20
20
|
"google-analytics-admin>=0.27.0",
|
|
21
21
|
"google-api-python-client>=2.100.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.16.0"
|
|
4
4
|
description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -11,7 +11,7 @@ license = { text = "MIT" }
|
|
|
11
11
|
keywords = ["mcp", "google-ads", "reddit-ads", "google-analytics", "ga4", "cursor", "marketing"]
|
|
12
12
|
dependencies = [
|
|
13
13
|
"fastmcp>=3.0.0",
|
|
14
|
-
"google-ads>=
|
|
14
|
+
"google-ads>=32.0.0",
|
|
15
15
|
"google-analytics-data>=0.20.0",
|
|
16
16
|
"google-analytics-admin>=0.27.0",
|
|
17
17
|
"google-api-python-client>=2.100.0",
|
|
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|
|
16
16
|
# Pin the API version so library upgrades don't silently break field names,
|
|
17
17
|
# enum values, or mutate operation structures. Bump this deliberately when
|
|
18
18
|
# migrating to a new API version — never let it float to the library default.
|
|
19
|
-
GOOGLE_ADS_API_VERSION = "
|
|
19
|
+
GOOGLE_ADS_API_VERSION = "v25"
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
|
|
@@ -28,11 +28,16 @@ def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
|
|
|
28
28
|
credentials = get_ads_credentials(config)
|
|
29
29
|
|
|
30
30
|
client_config = {
|
|
31
|
-
"developer_token": config.ads.developer_token,
|
|
32
31
|
"use_proto_plus": True,
|
|
33
32
|
"version": GOOGLE_ADS_API_VERSION,
|
|
34
33
|
}
|
|
35
34
|
|
|
35
|
+
# Access levels live on the Google Cloud project that owns the OAuth
|
|
36
|
+
# client since 2026-09-09; the developer token header is optional and
|
|
37
|
+
# ignored by the API. Send it only when someone still has one configured.
|
|
38
|
+
if config.ads.developer_token:
|
|
39
|
+
client_config["developer_token"] = config.ads.developer_token
|
|
40
|
+
|
|
36
41
|
if config.ads.login_customer_id:
|
|
37
42
|
client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
|
|
38
43
|
|
|
@@ -430,6 +430,7 @@ def draft_remove_conversion_action(
|
|
|
430
430
|
entity_id=str(conversion_action_id),
|
|
431
431
|
customer_id=customer_id,
|
|
432
432
|
changes={"conversion_action_id": str(conversion_action_id)},
|
|
433
|
+
requires_double_confirm=True,
|
|
433
434
|
)
|
|
434
435
|
store_plan(plan)
|
|
435
436
|
preview = plan.to_preview()
|
|
@@ -22,9 +22,8 @@ def _enum_introspection_client():
|
|
|
22
22
|
"""Memoized no-auth GoogleAdsClient used purely for enum introspection.
|
|
23
23
|
|
|
24
24
|
The client constructor doesn't make any network calls and doesn't
|
|
25
|
-
validate credentials
|
|
26
|
-
|
|
27
|
-
every enum_names() call after the first is essentially free.
|
|
25
|
+
validate credentials — perfect for reading the bundled enum protos. We
|
|
26
|
+
cache it so every enum_names() call after the first is essentially free.
|
|
28
27
|
"""
|
|
29
28
|
from google.ads.googleads.client import GoogleAdsClient
|
|
30
29
|
|
|
@@ -32,7 +31,6 @@ def _enum_introspection_client():
|
|
|
32
31
|
|
|
33
32
|
return GoogleAdsClient(
|
|
34
33
|
credentials=None,
|
|
35
|
-
developer_token="adloop-enum-introspection-not-used",
|
|
36
34
|
use_proto_plus=True,
|
|
37
35
|
version=GOOGLE_ADS_API_VERSION,
|
|
38
36
|
)
|
|
@@ -226,7 +226,7 @@ def _build_keyword_ideas_rest_body(
|
|
|
226
226
|
) -> dict:
|
|
227
227
|
"""Build the JSON body for the REST generateKeywordIdeas endpoint.
|
|
228
228
|
|
|
229
|
-
Schema follows google-ads REST
|
|
229
|
+
Schema follows the pinned google-ads REST version (camelCase; unchanged v24 → v25). Exactly one of
|
|
230
230
|
``keywordSeed`` / ``urlSeed`` / ``keywordAndUrlSeed`` is set based on
|
|
231
231
|
which inputs were provided.
|
|
232
232
|
"""
|
|
@@ -272,10 +272,9 @@ def _post_keyword_ideas_rest_page(
|
|
|
272
272
|
credentials = get_ads_credentials(config)
|
|
273
273
|
session = AuthorizedSession(credentials)
|
|
274
274
|
|
|
275
|
-
headers = {
|
|
276
|
-
|
|
277
|
-
"
|
|
278
|
-
}
|
|
275
|
+
headers = {"Content-Type": "application/json"}
|
|
276
|
+
if config.ads.developer_token:
|
|
277
|
+
headers["developer-token"] = config.ads.developer_token
|
|
279
278
|
if config.ads.login_customer_id:
|
|
280
279
|
headers["login-customer-id"] = config.ads.login_customer_id.replace("-", "")
|
|
281
280
|
|
|
@@ -63,15 +63,24 @@ def run_gaql(
|
|
|
63
63
|
# ---------------------------------------------------------------------------
|
|
64
64
|
|
|
65
65
|
_GAQL_ERROR_HINTS = {
|
|
66
|
+
"CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION": (
|
|
67
|
+
"The Google Cloud project that owns your OAuth client only has Test "
|
|
68
|
+
"access, which cannot reach production Google Ads accounts. Apply for "
|
|
69
|
+
"Explorer or Basic access on the project's Google Ads API Overview "
|
|
70
|
+
"page: https://console.cloud.google.com/google/ads-apis/overview"
|
|
71
|
+
),
|
|
72
|
+
# Pre-2026-09 wording: access levels moved from developer tokens to the
|
|
73
|
+
# Cloud project, but older API versions still answer with these names.
|
|
66
74
|
"DEVELOPER_TOKEN_NOT_APPROVED": (
|
|
67
|
-
"Your Google Ads
|
|
68
|
-
"Apply for
|
|
69
|
-
"
|
|
75
|
+
"Your project's Google Ads API access is at Test level, which cannot "
|
|
76
|
+
"reach production accounts. Apply for Explorer or Basic access on the "
|
|
77
|
+
"Google Cloud project's Google Ads API Overview page: "
|
|
78
|
+
"https://console.cloud.google.com/google/ads-apis/overview"
|
|
70
79
|
),
|
|
71
80
|
"DEVELOPER_TOKEN_INVALID": (
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
81
|
+
"The configured `ads.developer_token` is invalid. Since September 2026 "
|
|
82
|
+
"no token is needed at all: remove it from `~/.adloop/config.yaml`; "
|
|
83
|
+
"access is granted to the Google Cloud project that owns your OAuth client."
|
|
75
84
|
),
|
|
76
85
|
"EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE": (
|
|
77
86
|
"Fields used in ORDER BY or HAVING must also appear in the SELECT clause. "
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Run a Google Ads plan in validate-only mode for ``confirm_and_apply`` dry runs.
|
|
2
|
+
|
|
3
|
+
The dry run used to echo the plan back without asking Google anything, so a
|
|
4
|
+
change the API would reject only failed at the real apply. Wrapping the Ads
|
|
5
|
+
client here sends every mutate the plan would send with ``validate_only=True``
|
|
6
|
+
instead: Google checks the full request (field values, policy, references to
|
|
7
|
+
existing entities) and executes nothing.
|
|
8
|
+
|
|
9
|
+
Validate-only responses carry no results, so each validated call answers the
|
|
10
|
+
apply code with placeholder resource names. A later call that references one
|
|
11
|
+
(a plan whose second step uses what the first would have created) cannot be
|
|
12
|
+
validated meaningfully; it is skipped and counted instead of sent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
|
|
19
|
+
PLACEHOLDER = "adloop-validate-only"
|
|
20
|
+
|
|
21
|
+
_RESPONSE_FIELD_FOR_SERVICE = "campaign_result"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidateOnlyFailure(Exception):
|
|
25
|
+
"""Google rejected part of a validate-only request (partial failure)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidateOnlyClient:
|
|
29
|
+
"""A GoogleAdsClient stand-in whose services only ever validate.
|
|
30
|
+
|
|
31
|
+
Everything except ``get_service`` passes through (enums, ``get_type``),
|
|
32
|
+
and every service method except ``mutate*`` passes through too, so the
|
|
33
|
+
reads some apply paths do before mutating still run normally.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: object) -> None:
|
|
37
|
+
self._client = client
|
|
38
|
+
self.validated_calls = 0
|
|
39
|
+
self.skipped_calls = 0
|
|
40
|
+
|
|
41
|
+
def __getattr__(self, name: str) -> object:
|
|
42
|
+
return getattr(self._client, name)
|
|
43
|
+
|
|
44
|
+
def get_service(self, name: str, *args: object, **kwargs: object) -> object:
|
|
45
|
+
return _ValidateOnlyService(self, name, self._client.get_service(name, *args, **kwargs))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ValidateOnlyService:
|
|
49
|
+
def __init__(self, owner: ValidateOnlyClient, name: str, service: object) -> None:
|
|
50
|
+
self._owner = owner
|
|
51
|
+
self._name = name
|
|
52
|
+
self._service = service
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, attr: str) -> object:
|
|
55
|
+
target = getattr(self._service, attr)
|
|
56
|
+
if not attr.startswith("mutate"):
|
|
57
|
+
return target
|
|
58
|
+
|
|
59
|
+
def validate(request: object = None, **kwargs: object) -> object:
|
|
60
|
+
if request is None:
|
|
61
|
+
request = self._build_request(attr, kwargs)
|
|
62
|
+
request.validate_only = True
|
|
63
|
+
|
|
64
|
+
operations = list(_operations_of(request))
|
|
65
|
+
if PLACEHOLDER in str(request):
|
|
66
|
+
self._owner.skipped_calls += 1
|
|
67
|
+
else:
|
|
68
|
+
response = target(request=request)
|
|
69
|
+
self._owner.validated_calls += 1
|
|
70
|
+
failure = getattr(response, "partial_failure_error", None)
|
|
71
|
+
if failure is not None and getattr(failure, "code", 0):
|
|
72
|
+
raise ValidateOnlyFailure(getattr(failure, "message", "") or str(failure))
|
|
73
|
+
|
|
74
|
+
return _placeholder_response(request.customer_id, len(operations), attr == "mutate")
|
|
75
|
+
|
|
76
|
+
return validate
|
|
77
|
+
|
|
78
|
+
def _build_request(self, method: str, kwargs: dict) -> object:
|
|
79
|
+
request = self._owner._client.get_type(_request_type(self._name, method))
|
|
80
|
+
for key, value in kwargs.items():
|
|
81
|
+
if key in ("operations", "mutate_operations"):
|
|
82
|
+
getattr(request, key).extend(value)
|
|
83
|
+
else:
|
|
84
|
+
setattr(request, key, value)
|
|
85
|
+
return request
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _request_type(service_name: str, method: str) -> str:
|
|
89
|
+
"""``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest``."""
|
|
90
|
+
if method == "mutate":
|
|
91
|
+
return "Mutate" + service_name.removesuffix("Service") + "Request"
|
|
92
|
+
words = method.removeprefix("mutate_").split("_")
|
|
93
|
+
return "Mutate" + "".join(word.capitalize() for word in words) + "Request"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _operations_of(request: object) -> object:
|
|
97
|
+
if hasattr(request, "mutate_operations"):
|
|
98
|
+
return request.mutate_operations
|
|
99
|
+
return getattr(request, "operations", [])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _placeholder_response(customer_id: str, count: int, googleads_mutate: bool) -> object:
|
|
103
|
+
names = [f"customers/{customer_id}/{PLACEHOLDER}/{i}" for i in range(count)]
|
|
104
|
+
if googleads_mutate:
|
|
105
|
+
return SimpleNamespace(
|
|
106
|
+
mutate_operation_responses=[
|
|
107
|
+
SimpleNamespace(**{_RESPONSE_FIELD_FOR_SERVICE: SimpleNamespace(resource_name=n)})
|
|
108
|
+
for n in names
|
|
109
|
+
],
|
|
110
|
+
partial_failure_error=None,
|
|
111
|
+
)
|
|
112
|
+
return SimpleNamespace(
|
|
113
|
+
results=[SimpleNamespace(resource_name=n) for n in names],
|
|
114
|
+
partial_failure_error=None,
|
|
115
|
+
)
|
|
@@ -669,8 +669,13 @@ def update_responsive_search_ad(
|
|
|
669
669
|
if errors:
|
|
670
670
|
return {"error": "Validation failed", "details": errors}
|
|
671
671
|
|
|
672
|
+
url_warnings_found: list[str] = []
|
|
672
673
|
if has_url_change:
|
|
673
|
-
|
|
674
|
+
# _validate_urls returns (errors, warnings) — the same contract the
|
|
675
|
+
# create path and the sitelink path already unpack. Treating the tuple
|
|
676
|
+
# as a dict made every URL-only update fail with
|
|
677
|
+
# "'tuple' object has no attribute 'get'" before a plan existed.
|
|
678
|
+
url_check, url_warnings = _validate_urls([final_url])
|
|
674
679
|
if url_check.get(final_url):
|
|
675
680
|
return {
|
|
676
681
|
"error": "URL validation failed",
|
|
@@ -679,6 +684,10 @@ def update_responsive_search_ad(
|
|
|
679
684
|
f"{url_check[final_url]}. Ads MUST point to working URLs."
|
|
680
685
|
],
|
|
681
686
|
}
|
|
687
|
+
if url_warnings.get(final_url):
|
|
688
|
+
url_warnings_found.append(
|
|
689
|
+
f"final_url '{final_url}': {url_warnings[final_url]}"
|
|
690
|
+
)
|
|
682
691
|
|
|
683
692
|
changes: dict = {"ad_id": str(ad_id)}
|
|
684
693
|
if has_url_change:
|
|
@@ -703,8 +712,10 @@ def update_responsive_search_ad(
|
|
|
703
712
|
preview = plan.to_preview()
|
|
704
713
|
# Only warn when the creative text is actually being replaced — URL/path
|
|
705
714
|
# edits do not trigger the learning reset or policy re-review.
|
|
715
|
+
if url_warnings_found:
|
|
716
|
+
preview.setdefault("warnings", []).extend(url_warnings_found)
|
|
706
717
|
if has_headlines_change or has_descriptions_change:
|
|
707
|
-
preview
|
|
718
|
+
preview.setdefault("warnings", []).append(_RSA_TEXT_UPDATE_WARNING)
|
|
708
719
|
return preview
|
|
709
720
|
|
|
710
721
|
|
|
@@ -1232,7 +1243,11 @@ def update_ad_group(
|
|
|
1232
1243
|
max_cpc: float = 0,
|
|
1233
1244
|
) -> dict:
|
|
1234
1245
|
"""Draft an ad group update for name and manual CPC bid."""
|
|
1235
|
-
from adloop.safety.guards import
|
|
1246
|
+
from adloop.safety.guards import (
|
|
1247
|
+
SafetyViolation,
|
|
1248
|
+
check_bid_increase,
|
|
1249
|
+
check_blocked_operation,
|
|
1250
|
+
)
|
|
1236
1251
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
1237
1252
|
|
|
1238
1253
|
try:
|
|
@@ -1276,6 +1291,12 @@ def update_ad_group(
|
|
|
1276
1291
|
f"target governs spend under automated bidding. "
|
|
1277
1292
|
f"No change made."
|
|
1278
1293
|
)
|
|
1294
|
+
else:
|
|
1295
|
+
current_bid = _current_ad_group_cpc_bid(config, customer_id, ad_group_id)
|
|
1296
|
+
try:
|
|
1297
|
+
check_bid_increase(current_bid or 0, max_cpc, config.safety)
|
|
1298
|
+
except SafetyViolation as e:
|
|
1299
|
+
errors.append(str(e))
|
|
1279
1300
|
|
|
1280
1301
|
has_any_change = bool(ad_group_name.strip() or max_cpc)
|
|
1281
1302
|
if not has_any_change:
|
|
@@ -1384,6 +1405,9 @@ def remove_entity(
|
|
|
1384
1405
|
entity_id=entity_id,
|
|
1385
1406
|
customer_id=customer_id,
|
|
1386
1407
|
changes={"action": "REMOVE"},
|
|
1408
|
+
# Removal is irreversible; the preview asks for a second explicit
|
|
1409
|
+
# confirmation, as Reddit removals already did.
|
|
1410
|
+
requires_double_confirm=True,
|
|
1387
1411
|
)
|
|
1388
1412
|
store_plan(plan)
|
|
1389
1413
|
return plan.to_preview()
|
|
@@ -1574,6 +1598,7 @@ def update_campaign(
|
|
|
1574
1598
|
"""
|
|
1575
1599
|
from adloop.safety.guards import (
|
|
1576
1600
|
SafetyViolation,
|
|
1601
|
+
check_bid_increase,
|
|
1577
1602
|
check_blocked_operation,
|
|
1578
1603
|
check_budget_cap,
|
|
1579
1604
|
)
|
|
@@ -1628,6 +1653,14 @@ def update_campaign(
|
|
|
1628
1653
|
errors.append("campaign_id was not found")
|
|
1629
1654
|
elif strategy_for_cap != "TARGET_SPEND":
|
|
1630
1655
|
errors.append("max_cpc requires TARGET_SPEND bidding_strategy")
|
|
1656
|
+
else:
|
|
1657
|
+
# Only a ceiling that already exists has a baseline; a campaign
|
|
1658
|
+
# switching to Maximize Clicks has none to compare against.
|
|
1659
|
+
current_ceiling = _current_campaign_cpc_ceiling(config, customer_id, campaign_id)
|
|
1660
|
+
try:
|
|
1661
|
+
check_bid_increase(current_ceiling or 0, max_cpc, config.safety)
|
|
1662
|
+
except SafetyViolation as e:
|
|
1663
|
+
errors.append(str(e))
|
|
1631
1664
|
|
|
1632
1665
|
has_any_change = any([
|
|
1633
1666
|
bs,
|
|
@@ -1957,14 +1990,23 @@ def _extract_error_message(exc: Exception) -> str:
|
|
|
1957
1990
|
if isinstance(exc, GoogleAdsException) and exc.failure:
|
|
1958
1991
|
parts = []
|
|
1959
1992
|
for error in exc.failure.errors:
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1993
|
+
# The client returns proto-plus messages, on which WhichOneof
|
|
1994
|
+
# does not exist; read the underlying protobuf instead.
|
|
1995
|
+
pb = type(error).pb(error) if hasattr(type(error), "pb") else error
|
|
1996
|
+
code_field = pb.error_code.WhichOneof("error_code")
|
|
1997
|
+
code_name = "UNKNOWN"
|
|
1998
|
+
if code_field:
|
|
1999
|
+
number = getattr(pb.error_code, code_field)
|
|
2000
|
+
enum = pb.error_code.DESCRIPTOR.fields_by_name[code_field].enum_type
|
|
2001
|
+
value = enum.values_by_number.get(number)
|
|
2002
|
+
code_name = value.name if value else str(number)
|
|
2003
|
+
line = f"[{code_field}={code_name}]"
|
|
2004
|
+
if pb.message:
|
|
2005
|
+
line += f" {pb.message}"
|
|
2006
|
+
if pb.HasField("trigger"):
|
|
2007
|
+
kind = pb.trigger.WhichOneof("value")
|
|
2008
|
+
if kind:
|
|
2009
|
+
line += f" (trigger: {getattr(pb.trigger, kind)})"
|
|
1968
2010
|
parts.append(line)
|
|
1969
2011
|
if parts:
|
|
1970
2012
|
msg = "; ".join(parts)
|
|
@@ -2008,39 +2050,49 @@ def confirm_and_apply(
|
|
|
2008
2050
|
|
|
2009
2051
|
if dry_run:
|
|
2010
2052
|
preflight_checks: dict | None = None
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2053
|
+
validation: dict | None = None
|
|
2054
|
+
# Either way a failed check leaves dry_run_result unset, so two-phase
|
|
2055
|
+
# apply keeps refusing the real write.
|
|
2056
|
+
try:
|
|
2057
|
+
if is_reddit:
|
|
2058
|
+
# Reddit has no validate-only mode; the dry run re-reads the
|
|
2059
|
+
# target and re-runs the safety caps against live values.
|
|
2060
|
+
from adloop.reddit.write import preflight
|
|
2017
2061
|
|
|
2018
|
-
try:
|
|
2019
2062
|
preflight_checks = preflight(config, plan)
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2063
|
+
elif plan.operation != "create_key_event":
|
|
2064
|
+
# Google Ads checks the exact mutates with validate_only=True
|
|
2065
|
+
# and executes nothing.
|
|
2066
|
+
validation = _validate_with_google(config, plan)
|
|
2067
|
+
except Exception as e:
|
|
2068
|
+
error_message = _extract_error_message(e)
|
|
2069
|
+
log_mutation(
|
|
2070
|
+
config.safety.log_file,
|
|
2071
|
+
operation=plan.operation,
|
|
2072
|
+
customer_id=plan.customer_id,
|
|
2073
|
+
entity_type=plan.entity_type,
|
|
2074
|
+
entity_id=plan.entity_id,
|
|
2075
|
+
changes=plan.changes,
|
|
2076
|
+
dry_run=True,
|
|
2077
|
+
result="dry_run_failed",
|
|
2078
|
+
error=error_message,
|
|
2079
|
+
)
|
|
2080
|
+
checked_against = (
|
|
2081
|
+
"re-checked the target against Reddit"
|
|
2082
|
+
if is_reddit
|
|
2083
|
+
else "sent the change to Google Ads in validate-only mode"
|
|
2084
|
+
)
|
|
2085
|
+
return {
|
|
2086
|
+
"status": "DRY_RUN_FAILED",
|
|
2087
|
+
"plan_id": plan.plan_id,
|
|
2088
|
+
"operation": plan.operation,
|
|
2089
|
+
"error": error_message,
|
|
2090
|
+
"message": (
|
|
2091
|
+
f"The dry run {checked_against} and found a problem; "
|
|
2092
|
+
"nothing was changed. The real apply would fail the same "
|
|
2093
|
+
"way. Fix the cause and draft again."
|
|
2094
|
+
),
|
|
2095
|
+
}
|
|
2044
2096
|
log_mutation(
|
|
2045
2097
|
config.safety.log_file,
|
|
2046
2098
|
operation=plan.operation,
|
|
@@ -2074,6 +2126,18 @@ def confirm_and_apply(
|
|
|
2074
2126
|
"Reddit Ads has no validate-only mode: the dry run re-read the "
|
|
2075
2127
|
"target and re-checked the safety caps; nothing was sent."
|
|
2076
2128
|
)
|
|
2129
|
+
if validation is not None:
|
|
2130
|
+
response["checks"] = validation
|
|
2131
|
+
response["note"] = (
|
|
2132
|
+
"Google Ads validated this exact change (validate_only) and "
|
|
2133
|
+
"executed nothing."
|
|
2134
|
+
)
|
|
2135
|
+
if validation["skipped_calls"]:
|
|
2136
|
+
response["note"] += (
|
|
2137
|
+
f" {validation['skipped_calls']} later step(s) build on "
|
|
2138
|
+
"objects an earlier step would create, so Google could "
|
|
2139
|
+
"only validate the step(s) before them."
|
|
2140
|
+
)
|
|
2077
2141
|
if forced_by_config:
|
|
2078
2142
|
# The caller passed dry_run=false but safety.require_dry_run
|
|
2079
2143
|
# forced it back on. Tell them exactly why and how to unlock
|
|
@@ -2257,6 +2321,38 @@ def _existing_negative_geo_exclusions(
|
|
|
2257
2321
|
return ids
|
|
2258
2322
|
|
|
2259
2323
|
|
|
2324
|
+
def _current_ad_group_cpc_bid(
|
|
2325
|
+
config: AdLoopConfig, customer_id: str, ad_group_id: str
|
|
2326
|
+
) -> float | None:
|
|
2327
|
+
"""The ad group's current manual CPC bid in account currency, or None."""
|
|
2328
|
+
from adloop.ads.gaql import execute_query
|
|
2329
|
+
|
|
2330
|
+
rows = execute_query(config, customer_id, f"""
|
|
2331
|
+
SELECT ad_group.cpc_bid_micros
|
|
2332
|
+
FROM ad_group
|
|
2333
|
+
WHERE ad_group.id = {ad_group_id}
|
|
2334
|
+
LIMIT 1
|
|
2335
|
+
""")
|
|
2336
|
+
micros = rows[0].get("ad_group.cpc_bid_micros") if rows else None
|
|
2337
|
+
return int(micros) / 1_000_000 if micros else None
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
def _current_campaign_cpc_ceiling(
|
|
2341
|
+
config: AdLoopConfig, customer_id: str, campaign_id: str
|
|
2342
|
+
) -> float | None:
|
|
2343
|
+
"""The campaign's current Maximize Clicks CPC ceiling, or None if unset."""
|
|
2344
|
+
from adloop.ads.gaql import execute_query
|
|
2345
|
+
|
|
2346
|
+
rows = execute_query(config, customer_id, f"""
|
|
2347
|
+
SELECT campaign.target_spend.cpc_bid_ceiling_micros
|
|
2348
|
+
FROM campaign
|
|
2349
|
+
WHERE campaign.id = {campaign_id}
|
|
2350
|
+
LIMIT 1
|
|
2351
|
+
""")
|
|
2352
|
+
micros = rows[0].get("campaign.target_spend.cpc_bid_ceiling_micros") if rows else None
|
|
2353
|
+
return int(micros) / 1_000_000 if micros else None
|
|
2354
|
+
|
|
2355
|
+
|
|
2260
2356
|
def _ad_group_campaign_bidding_strategy(
|
|
2261
2357
|
config: AdLoopConfig, customer_id: str, ad_group_id: str
|
|
2262
2358
|
) -> str | None:
|
|
@@ -2851,8 +2947,20 @@ def _extract_resource_name(resp: object) -> str:
|
|
|
2851
2947
|
return ""
|
|
2852
2948
|
|
|
2853
2949
|
|
|
2854
|
-
def
|
|
2855
|
-
"""
|
|
2950
|
+
def _validate_with_google(config: AdLoopConfig, plan: object) -> dict:
|
|
2951
|
+
"""Send a Google Ads plan with validate_only=True; raises if Google rejects it."""
|
|
2952
|
+
return _execute_plan(config, plan, validate_only=True)
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def _execute_plan(
|
|
2956
|
+
config: AdLoopConfig, plan: object, *, validate_only: bool = False
|
|
2957
|
+
) -> dict:
|
|
2958
|
+
"""Dispatch to the right API call based on plan.operation.
|
|
2959
|
+
|
|
2960
|
+
With ``validate_only`` the plan's Google Ads mutates are sent with
|
|
2961
|
+
``validate_only=True`` (nothing executes) and the return value reports
|
|
2962
|
+
how many calls Google validated; any rejection raises.
|
|
2963
|
+
"""
|
|
2856
2964
|
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
2857
2965
|
|
|
2858
2966
|
# Reddit plans have their own executors and never touch Google: the
|
|
@@ -2873,6 +2981,25 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
|
2873
2981
|
client = get_ads_client(config)
|
|
2874
2982
|
cid = normalize_customer_id(plan.customer_id)
|
|
2875
2983
|
|
|
2984
|
+
if validate_only:
|
|
2985
|
+
from adloop.ads.validate_only import ValidateOnlyClient
|
|
2986
|
+
|
|
2987
|
+
validator = ValidateOnlyClient(client)
|
|
2988
|
+
result = _dispatch_ads_plan(validator, cid, plan)
|
|
2989
|
+
# Multi-step applies catch their own errors and report them in the
|
|
2990
|
+
# result instead of raising; in a dry run that is still a failure.
|
|
2991
|
+
if isinstance(result, dict) and result.get("error"):
|
|
2992
|
+
raise ValueError(result["error"])
|
|
2993
|
+
return {
|
|
2994
|
+
"validated_calls": validator.validated_calls,
|
|
2995
|
+
"skipped_calls": validator.skipped_calls,
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
return _dispatch_ads_plan(client, cid, plan)
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
|
|
3002
|
+
"""Run a Google Ads plan against ``client`` (real or validate-only)."""
|
|
2876
3003
|
# Conversion-action CRUD lives in its own module; import lazily so the
|
|
2877
3004
|
# dispatch table (and this module) don't take the dependency at import time.
|
|
2878
3005
|
from adloop.ads.conversion_actions import (
|
|
@@ -33,9 +33,13 @@ _GOOGLE_CLOUD_INSTRUCTIONS = """\
|
|
|
33
33
|
│ → https://console.cloud.google.com/apis/credentials │
|
|
34
34
|
│ Download the JSON file. │
|
|
35
35
|
│ │
|
|
36
|
-
│ 5.
|
|
37
|
-
│ → https://
|
|
38
|
-
│
|
|
36
|
+
│ 5. Apply for Google Ads API access ON THE PROJECT │
|
|
37
|
+
│ → https://console.cloud.google.com/google/ads-apis/overview │
|
|
38
|
+
│ New projects start at Test level (test accounts only). │
|
|
39
|
+
│ "Upgrade access level": Explorer is granted automatically │
|
|
40
|
+
│ (2,880 ops/day); Basic (15,000/day) needs the consent │
|
|
41
|
+
│ screen published ("In production") first. │
|
|
42
|
+
│ No manager account (MCC) and no developer token needed. │
|
|
39
43
|
└─────────────────────────────────────────────────────────────────┘
|
|
40
44
|
"""
|
|
41
45
|
|
|
@@ -77,9 +81,11 @@ def _validate_customer_id(raw: str) -> str | None:
|
|
|
77
81
|
return None
|
|
78
82
|
|
|
79
83
|
|
|
80
|
-
def _prompt_customer_id(label: str, default: str = "") -> str:
|
|
84
|
+
def _prompt_customer_id(label: str, default: str = "", required: bool = True) -> str:
|
|
81
85
|
while True:
|
|
82
|
-
value = _prompt(label, default=default)
|
|
86
|
+
value = _prompt(label, default=default, required=required)
|
|
87
|
+
if not value and not required:
|
|
88
|
+
return ""
|
|
83
89
|
formatted = _format_customer_id(value)
|
|
84
90
|
err = _validate_customer_id(formatted)
|
|
85
91
|
if err:
|
|
@@ -183,9 +189,10 @@ def _generate_config_yaml(
|
|
|
183
189
|
f' property_id: "{property_id}"',
|
|
184
190
|
"",
|
|
185
191
|
"ads:",
|
|
192
|
+
" # Legacy; API access comes from the Google Cloud project since 2026-09.",
|
|
186
193
|
f' developer_token: "{developer_token}"',
|
|
187
194
|
f' customer_id: "{customer_id}"',
|
|
188
|
-
" # MCC / Manager Account ID (
|
|
195
|
+
" # MCC / Manager Account ID (only if using a manager account)",
|
|
189
196
|
f' login_customer_id: "{login_customer_id}"',
|
|
190
197
|
"",
|
|
191
198
|
"gsc:",
|
|
@@ -604,23 +611,29 @@ def run_init_wizard() -> None:
|
|
|
604
611
|
)
|
|
605
612
|
|
|
606
613
|
step_num = 4
|
|
607
|
-
_step_header(step_num, "Google Ads
|
|
608
|
-
_print("
|
|
609
|
-
_print("
|
|
614
|
+
_step_header(step_num, "Google Ads API access")
|
|
615
|
+
_print(" Since September 2026 API access belongs to your Google Cloud")
|
|
616
|
+
_print(" project, not to a developer token. Apply on the project's page:")
|
|
617
|
+
_print(" → https://console.cloud.google.com/google/ads-apis/overview")
|
|
618
|
+
_print(" Leave the token empty unless you still run an old setup.")
|
|
610
619
|
_print()
|
|
611
620
|
developer_token = _prompt(
|
|
612
|
-
"Developer
|
|
621
|
+
"Developer token (legacy, optional)",
|
|
613
622
|
default=_existing("ads", "developer_token"),
|
|
623
|
+
required=False,
|
|
614
624
|
)
|
|
615
625
|
|
|
616
|
-
#
|
|
626
|
+
# Manager account: optional since access no longer comes from an MCC.
|
|
627
|
+
# Still needed to reach several accounts through one login.
|
|
617
628
|
step_num += 1
|
|
618
|
-
_step_header(step_num, "
|
|
619
|
-
_print("
|
|
629
|
+
_step_header(step_num, "Manager Account (MCC), optional")
|
|
630
|
+
_print(" Only if you manage several accounts through a manager account:")
|
|
631
|
+
_print(" its ID is in the top bar of the MCC. Leave empty otherwise.")
|
|
620
632
|
_print()
|
|
621
633
|
login_customer_id = _prompt_customer_id(
|
|
622
634
|
"MCC Account ID (XXX-XXX-XXXX)",
|
|
623
635
|
default=_existing("ads", "login_customer_id"),
|
|
636
|
+
required=False,
|
|
624
637
|
)
|
|
625
638
|
|
|
626
639
|
# OAuth + auto-discovery
|
|
@@ -244,6 +244,9 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
244
244
|
- `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
|
|
245
245
|
- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer.
|
|
246
246
|
- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link.
|
|
247
|
+
- A Google Ads `dry_run=true` sends the exact change to Google with `validate_only` — Google checks it and executes nothing. `DRY_RUN_FAILED` carries Google's error and means the real apply would fail the same way; fix the cause and draft again. Steps that build on an object an earlier step would create (e.g. keywords for a new negative list) are counted in `checks.skipped_calls` instead of validated.
|
|
248
|
+
- `max_bid_increase_pct` (default 100) applies to Google Ads bid raises too: `update_ad_group` max_cpc on MANUAL_CPC and `update_campaign` max_cpc on an existing Maximize Clicks ceiling. A first-time ceiling has no baseline and is not capped.
|
|
249
|
+
- New Google Ads ad groups are created enabled; they cannot serve until one of their ads (created PAUSED) is enabled.
|
|
247
250
|
- `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations.
|
|
248
251
|
- `two_phase_apply: true` in config (always on for AdLoop Cloud) refuses `dry_run=false` with status `DRY_RUN_REQUIRED` until that plan_id has completed one `dry_run=true` pass. Run the dry run, show the user, then apply — do not retry `dry_run=false` in a loop.
|
|
249
252
|
- All operations (including dry runs) are logged to `~/.adloop/audit.log`.
|
|
@@ -110,8 +110,13 @@ def _build_orchestration_instructions() -> str:
|
|
|
110
110
|
"`dry_run=false` after the user explicitly approves the preview. "
|
|
111
111
|
"`require_dry_run` in config can override this.\n"
|
|
112
112
|
"- Respect the config's `max_daily_budget` cap.\n"
|
|
113
|
-
"- New campaigns
|
|
113
|
+
"- New Google Ads campaigns and RSAs, and every new Reddit campaign, "
|
|
114
|
+
"ad group and ad, are created PAUSED. A new Google Ads ad group is "
|
|
115
|
+
"enabled but cannot serve until one of its (paused) ads is enabled. "
|
|
114
116
|
"The user must enable them after review.\n"
|
|
117
|
+
"- A Google Ads dry run sends the exact change to Google with "
|
|
118
|
+
"validate_only (nothing executes); DRY_RUN_FAILED means the real "
|
|
119
|
+
"apply would fail the same way.\n"
|
|
115
120
|
"- One change at a time — don't batch unrelated writes.\n\n"
|
|
116
121
|
"PRE-WRITE CHECKS (before any `draft_*`):\n"
|
|
117
122
|
"- BROAD match keywords require Smart Bidding (MAXIMIZE_CONVERSIONS, "
|
|
@@ -251,26 +256,36 @@ def _structured_error(fn_name: str, exc: Exception) -> dict:
|
|
|
251
256
|
err = str(exc)
|
|
252
257
|
err_lower = err.lower()
|
|
253
258
|
|
|
254
|
-
|
|
259
|
+
# Access levels belong to the Google Cloud project that owns the OAuth
|
|
260
|
+
# client (developer tokens were sunset on 2026-09-09). v25+ names the
|
|
261
|
+
# project; older API versions still answer with the token wording.
|
|
262
|
+
if (
|
|
263
|
+
"cloud_project_not_approved_for_production" in err_lower
|
|
264
|
+
or "developer_token_not_approved" in err_lower
|
|
265
|
+
or "only approved for use with test accounts" in err_lower
|
|
266
|
+
):
|
|
255
267
|
return {
|
|
256
268
|
"error": (
|
|
257
|
-
"Google Ads authorization failed —
|
|
258
|
-
"
|
|
269
|
+
"Google Ads authorization failed — your Google Cloud project's "
|
|
270
|
+
"API access level (Test) cannot reach production accounts."
|
|
259
271
|
),
|
|
260
272
|
"hint": (
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
273
|
+
"Open the project's Google Ads API Overview page "
|
|
274
|
+
"(https://console.cloud.google.com/google/ads-apis/overview) and "
|
|
275
|
+
"apply for Explorer access (usually granted automatically) or "
|
|
276
|
+
"Basic access (needs the OAuth consent screen brand-verified: "
|
|
277
|
+
"External and In production). Or switch AdLoop to a test account."
|
|
264
278
|
),
|
|
265
|
-
"auth_error": "
|
|
279
|
+
"auth_error": "CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION",
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
if "developer_token_invalid" in err_lower or "developer token is not valid" in err_lower:
|
|
269
283
|
return {
|
|
270
|
-
"error": "Google Ads authentication failed — developer token is invalid.",
|
|
284
|
+
"error": "Google Ads authentication failed — the configured developer token is invalid.",
|
|
271
285
|
"hint": (
|
|
272
|
-
"
|
|
273
|
-
"
|
|
286
|
+
"Since September 2026 no developer token is needed: remove "
|
|
287
|
+
"`ads.developer_token` from `~/.adloop/config.yaml`. API access "
|
|
288
|
+
"belongs to the Google Cloud project that owns your OAuth client. "
|
|
274
289
|
"OAuth is working if GA4 tools succeed."
|
|
275
290
|
),
|
|
276
291
|
"auth_error": "DEVELOPER_TOKEN_INVALID",
|
|
@@ -2263,9 +2278,15 @@ def confirm_and_apply(
|
|
|
2263
2278
|
DRY_RUN_REQUIRED until this plan_id has completed one dry_run=true
|
|
2264
2279
|
pass. Run the dry run, show it to the user, then apply for real.
|
|
2265
2280
|
|
|
2281
|
+
Google Ads plans: the dry run sends the exact mutates to Google with
|
|
2282
|
+
validate_only=True, so Google checks the change and executes nothing
|
|
2283
|
+
(`checks` counts the validated calls). Steps that build on an object an
|
|
2284
|
+
earlier step would create cannot be validated and are counted as skipped.
|
|
2285
|
+
|
|
2266
2286
|
Reddit plans: Reddit has no validate-only mode, so the dry run re-reads
|
|
2267
|
-
the target entity and re-checks the safety caps (returned as `checks`)
|
|
2268
|
-
|
|
2287
|
+
the target entity and re-checks the safety caps (returned as `checks`).
|
|
2288
|
+
|
|
2289
|
+
Either way, a DRY_RUN_FAILED result means the real apply would also fail.
|
|
2269
2290
|
|
|
2270
2291
|
The plan_id comes from a prior draft_* or pause/enable tool call.
|
|
2271
2292
|
"""
|
|
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
|
|
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
|
|
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
|
|
File without changes
|