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/__init__.py +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
adloop/ga4/tracking.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""GA4 tracking/event tools — list custom events and their volume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from adloop.config import AdLoopConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_tracking_events(
|
|
12
|
+
config: AdLoopConfig,
|
|
13
|
+
*,
|
|
14
|
+
property_id: str = "",
|
|
15
|
+
date_range_start: str = "28daysAgo",
|
|
16
|
+
date_range_end: str = "today",
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""List all GA4 events and their event count for the given date range."""
|
|
19
|
+
from adloop.ga4.reports import run_ga4_report
|
|
20
|
+
|
|
21
|
+
result = run_ga4_report(
|
|
22
|
+
config,
|
|
23
|
+
property_id=property_id,
|
|
24
|
+
dimensions=["eventName"],
|
|
25
|
+
metrics=["eventCount"],
|
|
26
|
+
date_range_start=date_range_start,
|
|
27
|
+
date_range_end=date_range_end,
|
|
28
|
+
limit=500,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if "rows" in result:
|
|
32
|
+
result["rows"].sort(
|
|
33
|
+
key=lambda r: int(r.get("eventCount", "0")), reverse=True
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return result
|
|
File without changes
|
adloop/safety/audit.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Mutation audit logging — every write operation is logged locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log_mutation(
|
|
12
|
+
log_file: str,
|
|
13
|
+
*,
|
|
14
|
+
operation: str,
|
|
15
|
+
customer_id: str = "",
|
|
16
|
+
entity_type: str = "",
|
|
17
|
+
entity_id: str = "",
|
|
18
|
+
changes: dict[str, Any] | None = None,
|
|
19
|
+
dry_run: bool = True,
|
|
20
|
+
result: str = "success",
|
|
21
|
+
error: str = "",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Append a mutation record to the audit log file."""
|
|
24
|
+
path = Path(log_file).expanduser()
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
record = {
|
|
28
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
29
|
+
"operation": operation,
|
|
30
|
+
"customer_id": customer_id,
|
|
31
|
+
"entity_type": entity_type,
|
|
32
|
+
"entity_id": entity_id,
|
|
33
|
+
"changes": changes or {},
|
|
34
|
+
"dry_run": dry_run,
|
|
35
|
+
"result": result,
|
|
36
|
+
"error": error,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with open(path, "a") as f:
|
|
40
|
+
f.write(json.dumps(record) + "\n")
|
adloop/safety/guards.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Safety guards — budget caps, bid limits, and blocked operation enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from adloop.config import SafetyConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SafetyViolation(Exception):
|
|
12
|
+
"""Raised when a proposed change violates safety constraints."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_budget_cap(daily_budget: float, config: SafetyConfig) -> None:
|
|
16
|
+
"""Reject if proposed daily budget exceeds configured maximum."""
|
|
17
|
+
if daily_budget > config.max_daily_budget:
|
|
18
|
+
raise SafetyViolation(
|
|
19
|
+
f"Daily budget {daily_budget:.2f} exceeds maximum {config.max_daily_budget:.2f}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_bid_increase(current_bid: float, proposed_bid: float, config: SafetyConfig) -> None:
|
|
24
|
+
"""Reject if bid increase percentage exceeds configured maximum."""
|
|
25
|
+
if current_bid <= 0:
|
|
26
|
+
return
|
|
27
|
+
increase_pct = ((proposed_bid - current_bid) / current_bid) * 100
|
|
28
|
+
if increase_pct > config.max_bid_increase_pct:
|
|
29
|
+
raise SafetyViolation(
|
|
30
|
+
f"Bid increase {increase_pct:.0f}% exceeds maximum {config.max_bid_increase_pct}%"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_blocked_operation(operation: str, config: SafetyConfig) -> None:
|
|
35
|
+
"""Reject if operation is in the blocked list."""
|
|
36
|
+
if operation in config.blocked_operations:
|
|
37
|
+
raise SafetyViolation(f"Operation '{operation}' is blocked by configuration")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def requires_double_confirmation(operation: str, **kwargs: object) -> bool:
|
|
41
|
+
"""Return True if this operation is destructive enough to need double confirmation.
|
|
42
|
+
|
|
43
|
+
Triggers on:
|
|
44
|
+
- Any delete or remove operation
|
|
45
|
+
- Budget increases >50%
|
|
46
|
+
"""
|
|
47
|
+
if "delete" in operation or "remove" in operation:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
current = kwargs.get("current_budget")
|
|
51
|
+
proposed = kwargs.get("proposed_budget")
|
|
52
|
+
if isinstance(current, (int, float)) and isinstance(proposed, (int, float)) and current > 0:
|
|
53
|
+
if ((proposed - current) / current) > 0.5:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
return False
|
adloop/safety/preview.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Change preview formatting — structured output for proposed mutations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ChangePlan:
|
|
13
|
+
"""A proposed change that must be confirmed before execution."""
|
|
14
|
+
|
|
15
|
+
plan_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
|
16
|
+
operation: str = ""
|
|
17
|
+
entity_type: str = ""
|
|
18
|
+
entity_id: str = ""
|
|
19
|
+
customer_id: str = ""
|
|
20
|
+
changes: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
22
|
+
requires_double_confirm: bool = False
|
|
23
|
+
dry_run_result: dict[str, Any] | None = None
|
|
24
|
+
|
|
25
|
+
def to_preview(self) -> dict[str, Any]:
|
|
26
|
+
"""Format as a human-readable preview dict for the AI to present."""
|
|
27
|
+
return {
|
|
28
|
+
"plan_id": self.plan_id,
|
|
29
|
+
"operation": self.operation,
|
|
30
|
+
"entity_type": self.entity_type,
|
|
31
|
+
"entity_id": self.entity_id,
|
|
32
|
+
"customer_id": self.customer_id,
|
|
33
|
+
"changes": self.changes,
|
|
34
|
+
"requires_double_confirm": self.requires_double_confirm,
|
|
35
|
+
"status": "PENDING_CONFIRMATION",
|
|
36
|
+
"instructions": (
|
|
37
|
+
"Review the changes above. To apply, call confirm_and_apply "
|
|
38
|
+
f"with plan_id='{self.plan_id}' and dry_run=false."
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_pending_plans: dict[str, ChangePlan] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def store_plan(plan: ChangePlan) -> None:
|
|
47
|
+
"""Store a plan for later retrieval by confirm_and_apply."""
|
|
48
|
+
_pending_plans[plan.plan_id] = plan
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_plan(plan_id: str) -> ChangePlan | None:
|
|
52
|
+
"""Retrieve a stored plan by ID."""
|
|
53
|
+
return _pending_plans.get(plan_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remove_plan(plan_id: str) -> None:
|
|
57
|
+
"""Remove a plan after execution."""
|
|
58
|
+
_pending_plans.pop(plan_id, None)
|