adloop 0.1.0__py3-none-any.whl

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/auth.py ADDED
@@ -0,0 +1,132 @@
1
+ """Google API authentication — OAuth 2.0 and service account support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from google.auth.credentials import Credentials
10
+
11
+ from adloop.config import AdLoopConfig
12
+
13
+ # Request all scopes in a single OAuth flow so one token works for both
14
+ # GA4 and Google Ads. Without this, separate tokens would constantly
15
+ # overwrite each other at the same token_path.
16
+ _ALL_SCOPES = [
17
+ "https://www.googleapis.com/auth/analytics.readonly",
18
+ "https://www.googleapis.com/auth/analytics.edit",
19
+ "https://www.googleapis.com/auth/adwords",
20
+ ]
21
+
22
+ _GA4_SCOPES = [
23
+ "https://www.googleapis.com/auth/analytics.readonly",
24
+ "https://www.googleapis.com/auth/analytics.edit",
25
+ ]
26
+
27
+ _ADS_SCOPES = [
28
+ "https://www.googleapis.com/auth/adwords",
29
+ ]
30
+
31
+
32
+ def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
33
+ """Return authenticated credentials for GA4 APIs."""
34
+ creds_path = Path(config.google.credentials_path).expanduser()
35
+
36
+ if creds_path.exists():
37
+ import json
38
+
39
+ with open(creds_path) as f:
40
+ creds_info = json.load(f)
41
+
42
+ if creds_info.get("type") == "service_account":
43
+ from google.oauth2 import service_account
44
+
45
+ return service_account.Credentials.from_service_account_file(
46
+ str(creds_path),
47
+ scopes=_GA4_SCOPES,
48
+ )
49
+
50
+ return _oauth_flow(config)
51
+
52
+ import google.auth
53
+
54
+ credentials, _ = google.auth.default(scopes=_GA4_SCOPES)
55
+ return credentials
56
+
57
+
58
+ def get_ads_credentials(config: AdLoopConfig) -> Credentials:
59
+ """Return authenticated credentials for Google Ads API."""
60
+ creds_path = Path(config.google.credentials_path).expanduser()
61
+
62
+ if creds_path.exists():
63
+ import json
64
+
65
+ with open(creds_path) as f:
66
+ creds_info = json.load(f)
67
+
68
+ if creds_info.get("type") == "service_account":
69
+ from google.oauth2 import service_account
70
+
71
+ return service_account.Credentials.from_service_account_file(
72
+ str(creds_path),
73
+ scopes=_ADS_SCOPES,
74
+ )
75
+
76
+ return _oauth_flow(config)
77
+
78
+ import google.auth
79
+
80
+ credentials, _ = google.auth.default(scopes=_ADS_SCOPES)
81
+ return credentials
82
+
83
+
84
+ def _oauth_flow(config: AdLoopConfig) -> Credentials:
85
+ """Run OAuth Desktop flow requesting all scopes (GA4 + Ads).
86
+
87
+ Uses a single token file for all scopes to avoid conflicts between
88
+ GA4 and Ads auth sharing the same token_path.
89
+ """
90
+ from google.auth.transport.requests import Request
91
+ from google.oauth2.credentials import Credentials as OAuthCredentials
92
+ from google_auth_oauthlib.flow import InstalledAppFlow
93
+
94
+ token_path = Path(config.google.token_path).expanduser()
95
+ creds_path = Path(config.google.credentials_path).expanduser()
96
+
97
+ creds = None
98
+ if token_path.exists():
99
+ creds = OAuthCredentials.from_authorized_user_file(
100
+ str(token_path), _ALL_SCOPES
101
+ )
102
+
103
+ if creds and creds.valid:
104
+ return creds
105
+
106
+ if creds and creds.expired and creds.refresh_token:
107
+ try:
108
+ creds.refresh(Request())
109
+ except Exception as exc:
110
+ err_str = str(exc).lower()
111
+ if "revoked" in err_str or "invalid_grant" in err_str:
112
+ token_path.unlink(missing_ok=True)
113
+ raise RuntimeError(
114
+ "OAuth token has been revoked or expired. "
115
+ "This typically happens when the Google Cloud consent screen "
116
+ "is in 'Testing' mode (tokens expire after 7 days). "
117
+ "Fix: (1) re-run any AdLoop tool to trigger re-authorization, "
118
+ "(2) publish the consent screen to 'In production' in Google "
119
+ "Cloud Console to prevent future expiry."
120
+ ) from exc
121
+ raise
122
+ else:
123
+ flow = InstalledAppFlow.from_client_secrets_file(
124
+ str(creds_path), _ALL_SCOPES
125
+ )
126
+ creds = flow.run_local_server(port=0)
127
+
128
+ token_path.parent.mkdir(parents=True, exist_ok=True)
129
+ with open(token_path, "w") as f:
130
+ f.write(creds.to_json())
131
+
132
+ return creds
adloop/cli.py ADDED
@@ -0,0 +1,375 @@
1
+ """Interactive setup wizard for first-time AdLoop configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ import textwrap
8
+ from pathlib import Path
9
+
10
+ _ADLOOP_DIR = Path.home() / ".adloop"
11
+ _CONFIG_PATH = _ADLOOP_DIR / "config.yaml"
12
+
13
+ _GOOGLE_CLOUD_INSTRUCTIONS = """\
14
+ ┌─────────────────────────────────────────────────────────────────┐
15
+ │ Google Cloud Setup Checklist │
16
+ │ │
17
+ │ Complete these steps in your browser before continuing: │
18
+ │ │
19
+ │ 1. Create (or select) a Google Cloud project │
20
+ │ → https://console.cloud.google.com/projectcreate │
21
+ │ │
22
+ │ 2. Enable these APIs: │
23
+ │ • Google Analytics Data API │
24
+ │ • Google Analytics Admin API │
25
+ │ • Google Ads API │
26
+ │ → https://console.cloud.google.com/apis/library │
27
+ │ │
28
+ │ 3. Create an OAuth consent screen (External, Testing mode OK) │
29
+ │ → https://console.cloud.google.com/apis/credentials/consent │
30
+ │ Add your email as a test user. │
31
+ │ │
32
+ │ 4. Create OAuth 2.0 Client ID (Desktop application) │
33
+ │ → https://console.cloud.google.com/apis/credentials │
34
+ │ Download the JSON file. │
35
+ │ │
36
+ │ 5. Get your Google Ads Developer Token from your MCC account │
37
+ │ → https://ads.google.com/aw/apicenter │
38
+ │ (You need a Manager Account / MCC) │
39
+ └─────────────────────────────────────────────────────────────────┘
40
+ """
41
+
42
+
43
+ def _print(msg: str = "") -> None:
44
+ print(msg)
45
+
46
+
47
+ def _prompt(label: str, default: str = "", required: bool = True) -> str:
48
+ suffix = f" [{default}]" if default else ""
49
+ while True:
50
+ raw = input(f" {label}{suffix}: ").strip()
51
+ value = raw or default
52
+ if value or not required:
53
+ return value
54
+ _print(" ⚠ This field is required.")
55
+
56
+
57
+ def _prompt_bool(label: str, default: bool = True) -> bool:
58
+ hint = "Y/n" if default else "y/N"
59
+ raw = input(f" {label} [{hint}]: ").strip().lower()
60
+ if not raw:
61
+ return default
62
+ return raw in ("y", "yes", "true", "1")
63
+
64
+
65
+ def _format_customer_id(raw: str) -> str:
66
+ """Normalize a customer ID to XXX-XXX-XXXX format."""
67
+ digits = re.sub(r"[^0-9]", "", raw)
68
+ if len(digits) == 10:
69
+ return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
70
+ return raw
71
+
72
+
73
+ def _validate_customer_id(raw: str) -> str | None:
74
+ digits = re.sub(r"[^0-9]", "", raw)
75
+ if len(digits) != 10:
76
+ return "Customer ID must be 10 digits (e.g. 123-456-7890)"
77
+ return None
78
+
79
+
80
+ def _prompt_customer_id(label: str, default: str = "") -> str:
81
+ while True:
82
+ value = _prompt(label, default=default)
83
+ formatted = _format_customer_id(value)
84
+ err = _validate_customer_id(formatted)
85
+ if err:
86
+ _print(f" ⚠ {err}")
87
+ continue
88
+ return formatted
89
+
90
+
91
+ def _validate_credentials_path(path_str: str) -> str | None:
92
+ p = Path(path_str).expanduser()
93
+ if not p.exists():
94
+ return f"File not found: {p}"
95
+ if not p.suffix == ".json":
96
+ return "Expected a .json file"
97
+ return None
98
+
99
+
100
+ def _prompt_credentials_path(default: str = "~/.adloop/credentials.json") -> str:
101
+ while True:
102
+ value = _prompt("Path to OAuth credentials JSON", default=default)
103
+ err = _validate_credentials_path(value)
104
+ if err:
105
+ _print(f" ⚠ {err}")
106
+ retry = _prompt_bool("Try again?", default=True)
107
+ if not retry:
108
+ return value
109
+ continue
110
+ return value
111
+
112
+
113
+ def _prompt_property_id(default: str = "") -> str:
114
+ while True:
115
+ value = _prompt("GA4 Property ID (numeric)", default=default)
116
+ if value and not value.isdigit():
117
+ _print(" ⚠ Property ID should be numeric (e.g. 519379787)")
118
+ continue
119
+ return value
120
+
121
+
122
+ def _generate_config_yaml(
123
+ *,
124
+ project_id: str,
125
+ credentials_path: str,
126
+ property_id: str,
127
+ developer_token: str,
128
+ customer_id: str,
129
+ login_customer_id: str,
130
+ max_daily_budget: float,
131
+ require_dry_run: bool,
132
+ ) -> str:
133
+ dry_run_str = "true" if require_dry_run else "false"
134
+ return textwrap.dedent(f"""\
135
+ # AdLoop configuration
136
+ # Generated by: adloop init
137
+ # Docs: https://github.com/your-org/adloop
138
+
139
+ google:
140
+ project_id: "{project_id}"
141
+ credentials_path: "{credentials_path}"
142
+ token_path: "~/.adloop/token.json"
143
+
144
+ ga4:
145
+ property_id: "{property_id}"
146
+
147
+ ads:
148
+ developer_token: "{developer_token}"
149
+ customer_id: "{customer_id}"
150
+ # MCC / Manager Account ID (required if using a manager account)
151
+ login_customer_id: "{login_customer_id}"
152
+
153
+ safety:
154
+ # Maximum daily budget AdLoop can set (safety cap)
155
+ max_daily_budget: {max_daily_budget}
156
+ max_bid_increase_pct: 100
157
+ # When true, confirm_and_apply always runs as dry_run regardless of the parameter
158
+ require_dry_run: {dry_run_str}
159
+ log_file: "~/.adloop/audit.log"
160
+ blocked_operations: []
161
+ """)
162
+
163
+
164
+ def _generate_cursor_snippet() -> str:
165
+ python_path = sys.executable
166
+ return textwrap.dedent(f"""\
167
+ {{
168
+ "mcpServers": {{
169
+ "adloop": {{
170
+ "command": "{python_path}",
171
+ "args": ["-m", "adloop"]
172
+ }}
173
+ }}
174
+ }}
175
+ """).strip()
176
+
177
+
178
+ def _generate_claude_code_snippet() -> str:
179
+ """Generate the Claude Code CLI command to add AdLoop as an MCP server."""
180
+ python_path = sys.executable
181
+ quoted = f'"{python_path}"' if " " in python_path else python_path
182
+ return f"claude mcp add --transport stdio adloop -- {quoted} -m adloop"
183
+
184
+
185
+ def _generate_claude_json_snippet() -> str:
186
+ """Generate .mcp.json configuration for Claude Code."""
187
+ python_path = sys.executable
188
+ return textwrap.dedent(f"""\
189
+ {{
190
+ "mcpServers": {{
191
+ "adloop": {{
192
+ "command": "{python_path}",
193
+ "args": ["-m", "adloop"]
194
+ }}
195
+ }}
196
+ }}
197
+ """).strip()
198
+
199
+
200
+ def _step_header(num: int, title: str) -> None:
201
+ _print()
202
+ _print(f" ── Step {num}: {title} ──")
203
+ _print()
204
+
205
+
206
+ def run_init_wizard() -> None:
207
+ """Interactive setup wizard for AdLoop."""
208
+ _print()
209
+ _print(" ╔═══════════════════════════════════╗")
210
+ _print(" ║ AdLoop Setup Wizard ║")
211
+ _print(" ╚═══════════════════════════════════╝")
212
+ _print()
213
+
214
+ existing_config = None
215
+ if _CONFIG_PATH.exists():
216
+ _print(f" Found existing config at {_CONFIG_PATH}")
217
+ if not _prompt_bool("Overwrite existing configuration?", default=False):
218
+ _print(" Keeping existing config. Exiting.")
219
+ return
220
+ try:
221
+ import yaml
222
+
223
+ with open(_CONFIG_PATH) as f:
224
+ existing_config = yaml.safe_load(f) or {}
225
+ except Exception:
226
+ existing_config = {}
227
+
228
+ def _existing(section: str, key: str, fallback: str = "") -> str:
229
+ if existing_config and section in existing_config:
230
+ return str(existing_config[section].get(key, fallback))
231
+ return fallback
232
+
233
+ # Step 1: Google Cloud instructions
234
+ _step_header(1, "Google Cloud Setup")
235
+ _print(_GOOGLE_CLOUD_INSTRUCTIONS)
236
+ input(" Press Enter when you've completed the steps above...")
237
+
238
+ # Step 2: Credentials path
239
+ _step_header(2, "OAuth Credentials")
240
+ credentials_path = _prompt_credentials_path(
241
+ default=_existing("google", "credentials_path", "~/.adloop/credentials.json")
242
+ )
243
+
244
+ # Step 3: Google Cloud Project ID
245
+ _step_header(3, "Google Cloud Project")
246
+ project_id = _prompt(
247
+ "Google Cloud Project ID",
248
+ default=_existing("google", "project_id"),
249
+ )
250
+
251
+ # Step 4: GA4 Property ID
252
+ _step_header(4, "Google Analytics (GA4)")
253
+ _print(" Find your GA4 Property ID at:")
254
+ _print(" → https://analytics.google.com → Admin → Property Settings")
255
+ _print()
256
+ property_id = _prompt_property_id(
257
+ default=_existing("ga4", "property_id"),
258
+ )
259
+
260
+ # Step 5: Developer Token
261
+ _step_header(5, "Google Ads Developer Token")
262
+ developer_token = _prompt(
263
+ "Developer Token",
264
+ default=_existing("ads", "developer_token"),
265
+ )
266
+
267
+ # Step 6: Customer ID
268
+ _step_header(6, "Google Ads Account IDs")
269
+ customer_id = _prompt_customer_id(
270
+ "Ads Customer ID (XXX-XXX-XXXX)",
271
+ default=_existing("ads", "customer_id"),
272
+ )
273
+ login_customer_id = _prompt_customer_id(
274
+ "MCC / Manager Account ID (XXX-XXX-XXXX)",
275
+ default=_existing("ads", "login_customer_id"),
276
+ )
277
+
278
+ # Step 7: Safety defaults
279
+ _step_header(7, "Safety Defaults")
280
+ budget_str = _prompt(
281
+ "Max daily budget cap (safety limit)",
282
+ default=str(_existing("safety", "max_daily_budget", "50")),
283
+ required=False,
284
+ )
285
+ try:
286
+ max_daily_budget = float(budget_str) if budget_str else 50.0
287
+ except ValueError:
288
+ max_daily_budget = 50.0
289
+ _print(" ⚠ Invalid number, using default 50.0")
290
+
291
+ require_dry_run = _prompt_bool(
292
+ "Require dry_run for all mutations? (recommended for setup)",
293
+ default=True,
294
+ )
295
+
296
+ # Write config
297
+ _print()
298
+ _print(" ── Writing Configuration ──")
299
+ _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
300
+
301
+ config_yaml = _generate_config_yaml(
302
+ project_id=project_id,
303
+ credentials_path=credentials_path,
304
+ property_id=property_id,
305
+ developer_token=developer_token,
306
+ customer_id=customer_id,
307
+ login_customer_id=login_customer_id,
308
+ max_daily_budget=max_daily_budget,
309
+ require_dry_run=require_dry_run,
310
+ )
311
+ _CONFIG_PATH.write_text(config_yaml)
312
+ _print(f" ✓ Config written to {_CONFIG_PATH}")
313
+
314
+ # Optional: Copy credentials.json to ~/.adloop/ if it's elsewhere
315
+ creds_expanded = Path(credentials_path).expanduser()
316
+ adloop_creds = _ADLOOP_DIR / "credentials.json"
317
+ if creds_expanded != adloop_creds and creds_expanded.exists():
318
+ if _prompt_bool(
319
+ f"Copy {creds_expanded.name} to {_ADLOOP_DIR}?", default=True
320
+ ):
321
+ import shutil
322
+
323
+ shutil.copy2(creds_expanded, adloop_creds)
324
+ _print(f" ✓ Credentials copied to {adloop_creds}")
325
+
326
+ # Optional OAuth
327
+ _print()
328
+ if _prompt_bool("Run OAuth authorization now? (opens browser)", default=True):
329
+ _print(" Starting OAuth flow...")
330
+ try:
331
+ from adloop.config import load_config
332
+ from adloop.auth import _oauth_flow
333
+
334
+ cfg = load_config(str(_CONFIG_PATH))
335
+ _oauth_flow(cfg)
336
+ _print(" ✓ OAuth token saved — AdLoop is ready to connect")
337
+ except Exception as exc:
338
+ _print(f" ✗ OAuth failed: {exc}")
339
+ _print(" You can retry later — any AdLoop tool call will trigger auth.")
340
+ else:
341
+ _print(" Skipped — OAuth will run automatically on first tool call.")
342
+
343
+ # MCP configuration snippets
344
+ _print()
345
+ _print(" ── MCP Configuration ──")
346
+
347
+ # Cursor
348
+ _print()
349
+ _print(" For Cursor, add to .cursor/mcp.json:")
350
+ _print()
351
+ cursor_snippet = _generate_cursor_snippet()
352
+ for line in cursor_snippet.splitlines():
353
+ _print(f" {line}")
354
+ _print()
355
+ _print(" Then copy .cursor/rules/adloop.mdc into your project.")
356
+
357
+ # Claude Code
358
+ _print()
359
+ _print(" For Claude Code, run:")
360
+ _print()
361
+ _print(f" {_generate_claude_code_snippet()}")
362
+ _print()
363
+ _print(" Or add to your project's .mcp.json:")
364
+ _print()
365
+ claude_snippet = _generate_claude_json_snippet()
366
+ for line in claude_snippet.splitlines():
367
+ _print(f" {line}")
368
+ _print()
369
+ _print(" Then copy .claude/rules/adloop.md and .claude/commands/ into your project.")
370
+
371
+ _print()
372
+ _print(" Restart your editor to pick up the MCP server.")
373
+ _print()
374
+ _print(" ✓ Setup complete!")
375
+ _print()
adloop/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """Load and validate AdLoop configuration from ~/.adloop/config.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class GoogleConfig:
14
+ project_id: str = ""
15
+ credentials_path: str = "~/.adloop/credentials.json"
16
+ token_path: str = "~/.adloop/token.json"
17
+
18
+
19
+ @dataclass
20
+ class GA4Config:
21
+ property_id: str = ""
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.property_id and not self.property_id.startswith("properties/"):
25
+ self.property_id = f"properties/{self.property_id}"
26
+
27
+
28
+ @dataclass
29
+ class AdsConfig:
30
+ developer_token: str = ""
31
+ customer_id: str = ""
32
+ login_customer_id: str = ""
33
+
34
+
35
+ @dataclass
36
+ class SafetyConfig:
37
+ max_daily_budget: float = 50.0
38
+ max_bid_increase_pct: int = 100
39
+ require_dry_run: bool = True
40
+ log_file: str = "~/.adloop/audit.log"
41
+ blocked_operations: list[str] = field(default_factory=list)
42
+
43
+
44
+ @dataclass
45
+ class AdLoopConfig:
46
+ google: GoogleConfig = field(default_factory=GoogleConfig)
47
+ ga4: GA4Config = field(default_factory=GA4Config)
48
+ ads: AdsConfig = field(default_factory=AdsConfig)
49
+ safety: SafetyConfig = field(default_factory=SafetyConfig)
50
+
51
+
52
+ def _resolve_path(path_str: str) -> Path:
53
+ """Expand ~ and env vars in a path string."""
54
+ return Path(os.path.expandvars(os.path.expanduser(path_str)))
55
+
56
+
57
+ def load_config(config_path: str | None = None) -> AdLoopConfig:
58
+ """Load configuration from YAML file.
59
+
60
+ Resolution order:
61
+ 1. Explicit ``config_path`` argument
62
+ 2. ``ADLOOP_CONFIG`` environment variable
63
+ 3. ``~/.adloop/config.yaml`` default
64
+ """
65
+ if config_path is None:
66
+ config_path = os.environ.get("ADLOOP_CONFIG", "~/.adloop/config.yaml")
67
+
68
+ path = _resolve_path(config_path)
69
+
70
+ if not path.exists():
71
+ return AdLoopConfig()
72
+
73
+ with open(path) as f:
74
+ raw = yaml.safe_load(f) or {}
75
+
76
+ google_raw = raw.get("google", {})
77
+ ga4_raw = raw.get("ga4", {})
78
+ ads_raw = raw.get("ads", {})
79
+ safety_raw = raw.get("safety", {})
80
+
81
+ return AdLoopConfig(
82
+ google=GoogleConfig(
83
+ project_id=google_raw.get("project_id", ""),
84
+ credentials_path=google_raw.get("credentials_path", "~/.adloop/credentials.json"),
85
+ token_path=google_raw.get("token_path", "~/.adloop/token.json"),
86
+ ),
87
+ ga4=GA4Config(
88
+ property_id=ga4_raw.get("property_id", ""),
89
+ ),
90
+ ads=AdsConfig(
91
+ developer_token=ads_raw.get("developer_token", ""),
92
+ customer_id=ads_raw.get("customer_id", ""),
93
+ login_customer_id=ads_raw.get("login_customer_id", ""),
94
+ ),
95
+ safety=SafetyConfig(
96
+ max_daily_budget=safety_raw.get("max_daily_budget", 50.0),
97
+ max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
98
+ require_dry_run=safety_raw.get("require_dry_run", True),
99
+ log_file=safety_raw.get("log_file", "~/.adloop/audit.log"),
100
+ blocked_operations=safety_raw.get("blocked_operations", []),
101
+ ),
102
+ )