From 4f3f986c7f0a84196e39e3578435d4b14695f6ae Mon Sep 17 00:00:00 2001 From: qinxun Date: Tue, 4 Aug 2026 14:58:13 +0800 Subject: [PATCH] feat: App Ad asset editing, billing, change history, and an identifier guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Commands - `ads assets` reads an App Ad's real asset list from `app_ad.*` with slot fill and per-orientation coverage. `ad_group_ad_asset_view` retains historical associations and can report more assets than the ad carries, so it is not used as the source of truth. - `ads set-assets` edits App Ad assets in place. Those fields are whole-field replacements, so an `update_mask` on `app_ad.images` silently drops anything omitted; the command reads current state and applies an add/remove delta on top. Caps, duplicates, removing an absent asset, and stripping every visual asset are rejected before the API call. This removes the need to rebuild an ad group per creative change, which left undeletable ads behind. - `billing show` reports funding, remaining balance, and runway. account_budget returns the NET spendable amount, so a prepay top-up shown as a gross figure in the UI yields a shorter runway than expected; `--tax-rate` prints a gross-equivalent column to reconcile the two. - `changes list` surfaces change_event history, supplying the bounded date window and LIMIT the resource requires. - Report presets: assets, network, daily-campaign. Video orientation is inferred from asset names because the API does not expose video aspect ratio; explicit ratio tokens take priority over words like "portrait", which creative names often use for the subject rather than the frame. Not available through the API and left to the web UI: promotional account credits, SKAdNetwork reports, and Google's per-orientation Ad Strength breakdown for App ads (asset_group.asset_coverage is Performance Max only). ## Identifier guard Building the above put real account IDs, an account balance, live ad copy, and internal creative naming into tests and docs. detect-secrets reported clean throughout, because it recognizes credentials, not business identifiers — a real customer ID is just a ten-digit number to it. CI is also the wrong layer. It runs on push, and this repository is public: by the time CI objects the value is already published, and git history keeps it after any later fix. The gate has to run before the commit. scripts/check_identifiers.py pairs two rules, wired into pre-commit: - Denylist: anything in .private-values (gitignored) fails. Precise, but only catches values someone remembered to write down. - Allowlist: every 8+ digit number, grouped payments-shaped ID, and email must appear in .identifier-allowlist.txt. This is the rule that catches values nobody knew to list, and while wiring it up it flagged a real account_budget ID still sitting in tests after a manual sweep for known IDs. Numeric separators are stripped before matching so a balance cannot hide behind underscores. The guard also rejected the first draft of its own test file, which had used real IDs as fixtures; sample values are now assembled from fragments so no literal identifier exists in a tracked file. Adding an allowlist line is deliberately a reviewable act: that line is where a reviewer asks whether the value is real. CI keeps the check as a backstop. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 + .gitignore | 1 + .identifier-allowlist.txt | 32 ++ .pre-commit-config.yaml | 30 ++ .private-values.example | 27 ++ CHANGELOG.md | 36 +- CONTRIBUTING.md | 32 ++ README.md | 64 +++- README.zh-CN.md | 80 ++++- pyproject.toml | 3 +- scripts/check_identifiers.py | 143 ++++++++ skills/google-ads/references/operations.md | 49 +++ src/google_ads_cli/__init__.py | 2 +- src/google_ads_cli/appads.py | 363 +++++++++++++++++++++ src/google_ads_cli/billing.py | 135 ++++++++ src/google_ads_cli/changes.py | 99 ++++++ src/google_ads_cli/cli.py | 178 ++++++++++ src/google_ads_cli/presets.py | 55 ++++ tests/test_appads.py | 165 ++++++++++ tests/test_billing_and_changes.py | 106 ++++++ tests/test_check_identifiers.py | 102 ++++++ uv.lock | 92 +++++- 22 files changed, 1794 insertions(+), 7 deletions(-) create mode 100644 .identifier-allowlist.txt create mode 100644 .pre-commit-config.yaml create mode 100644 .private-values.example create mode 100644 scripts/check_identifiers.py create mode 100644 src/google_ads_cli/appads.py create mode 100644 src/google_ads_cli/billing.py create mode 100644 src/google_ads_cli/changes.py create mode 100644 tests/test_appads.py create mode 100644 tests/test_billing_and_changes.py create mode 100644 tests/test_check_identifiers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2723d8a..6d50e77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,13 @@ jobs: if: matrix.python-version == '3.14' run: git ls-files -z | xargs -0 uv run --frozen detect-secrets-hook --baseline .secrets.baseline + # Backstop only. detect-secrets knows credentials, not business + # identifiers, and CI runs after a push has already made the repo public. + # The pre-commit hook is the gate that prevents publication. + - name: Scan tracked files for real account identifiers + if: matrix.python-version == '3.14' + run: python3 scripts/check_identifiers.py + - name: Test run: uv run --frozen pytest --cov=google_ads_cli --cov-report=term-missing --cov-fail-under=55 diff --git a/.gitignore b/.gitignore index d11c816..9cc32b2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ __pycache__/ .venv/ secrets/ .secrets/ +.private-values google-ads.yaml google-ads*.yaml credentials.json diff --git a/.identifier-allowlist.txt b/.identifier-allowlist.txt new file mode 100644 index 0000000..044958d --- /dev/null +++ b/.identifier-allowlist.txt @@ -0,0 +1,32 @@ +# Synthetic identifiers approved for docs, tests, and examples. +# +# `scripts/check_identifiers.py` fails on any 8+ digit number, grouped ID, or +# email address that is not listed here. Adding a line is deliberately a +# reviewable act: that is the moment to ask "is this value real?". +# +# Rule of thumb: only obviously-fake values belong here. If a number came out of +# a live account, do not allowlist it — replace it. + +# --- Placeholder account identifiers used across README, skill docs, tests --- +1234567890 +123456789 +111222333444 +555000111222 +555000333444 + +# --- Emails --- +noreply@anthropic.com + +# --- Placeholders inherited from existing docs/tests --- +1111111111 +2222222222 +3333333333 +987654321 +000000000 +8888888888 + +# --- Synthetic micros amounts used in tests (value * 1_000_000) --- +1000000000 +400000000 +150000000 +50000000 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..17e0d86 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# Local gates. This repository is public and pushes happen from a personal +# machine, so CI runs *after* the data would already be published — git history +# keeps a leaked value even if a later commit removes it. These hooks are +# therefore the gate that actually matters. +# +# Install once: uv run pre-commit install +repos: + - repo: local + hooks: + - id: no-real-identifiers + name: Block real account identifiers + entry: python3 scripts/check_identifiers.py + language: system + pass_filenames: true + require_serial: true + + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + name: Scan for credentials + args: ["--baseline", ".secrets.baseline"] + exclude: ^(\.secrets\.baseline|uv\.lock)$ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + - id: ruff-check + args: ["--fix"] + - id: ruff-format diff --git a/.private-values.example b/.private-values.example new file mode 100644 index 0000000..3a249bf --- /dev/null +++ b/.private-values.example @@ -0,0 +1,27 @@ +# Copy to `.private-values` (gitignored) and list the REAL values from the +# accounts you operate. `scripts/check_identifiers.py` hard-fails if any of them +# appears in a tracked file. +# +# This file is the denylist half of the guard. It only catches values you +# remembered to write down — the allowlist half (.identifier-allowlist.txt) +# is what catches the ones you forgot. Keep both. +# +# One value per line. Lines starting with # are ignored. +# Never commit the filled-in `.private-values` file. + +# --- Account identifiers --- +# 1234567890 # customer ID +# 12345678901 # campaign ID +# 123456789012 # ad group ID / ad ID / asset ID + +# --- People --- +# someone@example.com + +# --- Billing --- +# 1234-5678-9012-3456 # payments account ID +# 2830.19 # a real balance figure + +# --- Anything else that identifies the business --- +# Live ad headlines and descriptions currently serving +# Internal creative naming prefixes +# Account or client names diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cf12a..bae809e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,47 @@ Notable changes to this project are documented here. The format is based on ### Added -- CI secret scanning for all tracked files -- Targeted diagnostics for wrong OAuth users, expired OAuth grants, and transport failures +- `scripts/check_identifiers.py` blocks real account identifiers from entering this public + repository, plus a `pre-commit` config that runs it before every commit. `detect-secrets` + recognizes credentials, not business identifiers — a real customer ID, account balance, or + live ad headline is invisible to it. The check pairs a gitignored `.private-values` + denylist with an `.identifier-allowlist.txt` allowlist, so identifiers nobody thought to + list are caught too. + +## [0.2.0] - 2026-08-04 + +### Added + +- `gads ads assets AD_ID` reads an App Ad's real asset list from `app_ad.*`, with slot + fill and per-orientation coverage. `ad_group_ad_asset_view` retains historical + associations and can report more assets than the ad actually carries, so it is not + used as the source of truth. +- `gads ads set-assets AD_ID` edits App Ad assets in place. App Ad asset fields are + whole-field replacements, so the command reads current assets first and applies an + `--add-*`/`--remove-*` delta on top; per-ad-group caps, duplicates, removing an absent + asset, and stripping every visual asset are all rejected before the API is called. +- `gads billing show` reports account funding, remaining balance, and spend runway. + `account_budget` returns the net spendable amount, so `--tax-rate` prints a + gross-equivalent column that reconciles with the web UI's "Available funds". +- `gads changes list` surfaces `change_event` history (who changed what, when), supplying + the bounded date window and `LIMIT` the resource requires. +- Report presets `assets` (per-asset performance labels), `network` (Search / YouTube / + Display / Discover split), and `daily-campaign`. ### Changed +- CI secret scanning for all tracked files +- Targeted diagnostics for wrong OAuth users, expired OAuth grants, and transport failures - OAuth login always shows Google's account picker to prevent accidental account reuse - macOS uses the native DNS resolver for more reliable operation through VPN/TUN networks - Setup documentation now distinguishes MCC, OAuth user, OAuth client, and target customer +### Notes + +- Promotional account credits, SKAdNetwork reports, and Google's own per-orientation Ad + Strength breakdown for App ads are not exposed by the Google Ads API and remain + web-UI-only. `asset_group.asset_coverage` is Performance Max only. + ## [0.1.0] - 2026-07-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af98860..53f7d31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,37 @@ uv run gads --version Create a branch from `main` and keep each pull request focused on one change. +## Install the local gates first + +This repository is public. A value committed here stays in git history even after a later +commit removes it, so the gate that matters runs **before** the commit, not in CI: + +```bash +cp .private-values.example .private-values # then fill in YOUR real account values +uv run pre-commit install +``` + +`.private-values` is gitignored and must never be committed. + +## Never put real account data in the repository + +Examples, tests, and documentation must use synthetic identifiers only. This includes +customer/campaign/ad group/ad/asset IDs, account budget and billing IDs, payments account +numbers, balances, emails, live ad copy, and internal creative naming. + +`detect-secrets` does not help here — it recognizes credentials, not business identifiers. +A real customer ID is just a ten-digit number to it. `scripts/check_identifiers.py` covers +that gap with two rules: + +- **Denylist** — anything in `.private-values` fails. Precise, but only catches what someone + remembered to list. +- **Allowlist** — every 8+ digit number, grouped ID, and email must appear in + `.identifier-allowlist.txt`. This is the rule that catches values nobody knew to list yet. + +Adding a line to `.identifier-allowlist.txt` is intentionally a reviewable act: that line is +where a reviewer asks "is this value real?". Only allowlist obviously synthetic values. If a +number came out of a live account, replace it instead. + ## Checks Run all checks before opening a pull request: @@ -35,6 +66,7 @@ uv run ruff format . uv run ruff check . uv run ruff format --check . uv run pytest --cov=google_ads_cli --cov-fail-under=55 +python3 scripts/check_identifiers.py uv build ``` diff --git a/README.md b/README.md index 88ec76e..6f28c5a 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,14 @@ reviewable action. - Discover directly accessible accounts and recursive manager hierarchies - Run and validate arbitrary GAQL; discover valid Google Ads fields - Output tables, JSON, JSONL, or CSV -- Run curated account, campaign, ad group, ad, daily, and conversion reports +- Run curated account, campaign, ad group, ad, daily, conversion, per-asset, and ad-network reports - Inspect campaigns, budgets, ad groups, ads, assets, and conversion actions - Pause, enable, or remove campaigns and update daily budgets - Create an atomic App Campaign with budget, criteria, ad group, app ad, and assets - Upload image assets and create YouTube assets +- Inspect an App Ad's real assets and edit them in place without rebuilding the ad group +- Report account funding, tax-adjusted balance, and spend runway +- Review account change history (who changed what, when) - Resolve geographic and language constants - Apply versioned `GoogleAdsService.Mutate` YAML manifests - Keep a non-secret JSONL audit trail with deterministic plan hashes @@ -329,6 +332,63 @@ gads assets create-youtube VIDEO_ID --name "US Demo 15s" Google Ads assets are generally immutable. Stop one from serving by changing the ad or association that uses it. +## Edit App Ad assets in place + +An App Ad cannot be duplicated within its ad group or removed, but its **asset fields can +be updated**. Rebuilding the ad group for every creative change is unnecessary — and it +leaves undeletable ads behind. + +Read what the ad actually carries, with slot fill and orientation coverage: + +```bash +gads ads assets 111222333444 +``` + +`ad_group_ad_asset_view` is *not* the source of truth: it retains historical associations +and can report more assets than the ad has. These commands read `app_ad.*` instead. + +Asset fields are whole-field replacements, so an `update_mask` on `app_ad.images` drops +anything left out of the payload. `set-assets` reads the current assets first and applies +your delta on top: + +```bash +gads ads set-assets 111222333444 --add-video 555000111222 --remove-video 555000333444 +gads ads set-assets 111222333444 --add-video 555000111222 --validate-only +gads ads set-assets 111222333444 --add-video 555000111222 --execute +``` + +Use `--set-image`, `--set-video`, `--set-headline`, or `--set-description` to replace a +whole list. Per-ad-group caps, duplicate assets, removing an asset the ad does not have, +and stripping every visual asset are all rejected before anything reaches the API. + +Each change triggers an ad review, so batch creative edits into one call. + +## Check funding and runway + +```bash +gads billing show +gads billing show --tax-rate 0.06 +``` + +`account_budget` reports the **net** spendable amount. A prepay top-up shown as a gross +figure in the web UI arrives here already divided by the local tax rate, so the real +runway is shorter than the UI number suggests — `--tax-rate` prints a gross-equivalent +column to reconcile the two. Runway defaults to the summed daily budgets of enabled +campaigns; override it with `--daily-budget`. + +Promotional credits ("spend X, get X") are not exposed by the API at all. + +## Review change history + +```bash +gads changes list --days 14 +gads changes list --days 7 --resource-type CAMPAIGN_BUDGET +gads changes list --campaign-id 123456789 --limit 500 +``` + +`change_event` retains 30 days, requires a bounded date window, and requires a `LIMIT`; +this command supplies all three. + ## Use generic mutation manifests Dedicated commands cover common operations. The versioned manifest escape hatch covers @@ -444,6 +504,8 @@ Do not paste credentials into an issue. See [SECURITY.md](SECURITY.md) for priva - Mutations do not add blanket retries because Google Ads mutates do not provide a universal idempotency key. - Unit tests require no Google credentials and never contact or mutate a Google Ads account. +- A pre-commit hook blocks real account identifiers — IDs, balances, emails, live ad copy — + from entering this public repository. See [CONTRIBUTING.md](CONTRIBUTING.md). Review [SECURITY.md](SECURITY.md) before using the CLI in production. You remain responsible for account permissions, policy compliance, spend, and every command executed with diff --git a/README.zh-CN.md b/README.zh-CN.md index 11af18f..d6bfead 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,11 +37,14 @@ API 请求。 - 发现直接可访问账号和递归的经理账号层级 - 执行、校验任意 GAQL,并查询可用的 Google Ads 字段 - 输出表格、JSON、JSONL 或 CSV -- 执行账号、Campaign、Ad Group、广告、每日表现和转化等常用报表 +- 执行账号、Campaign、Ad Group、广告、每日表现、转化、单素材评级和广告网络分布等常用报表 - 查看 Campaign、预算、Ad Group、广告、素材和转化操作 - 暂停、启用或移除 Campaign,修改每日预算 - 原子化创建包含预算、定向、Ad Group、App Ad 和素材的 App Campaign - 上传图片素材、创建 YouTube 素材 +- 查看 App Ad 真实挂载的素材,并原位增删,无需重建 Ad Group +- 查看账户余额、扣税后的可投净额和可投天数 +- 查看账户变更历史(谁在什么时候改了什么) - 查询地理位置和语言常量 - 执行带 API 版本的 `GoogleAdsService.Mutate` YAML 清单 - 用确定性的计划哈希保存不含密钥的 JSONL 审计记录 @@ -319,6 +322,57 @@ gads assets create-youtube VIDEO_ID --name "US Demo 15s" Google Ads 素材通常不可修改。要停止某个素材投放,请修改引用它的广告或关联。 +## 原位修改 App Ad 素材 + +App Ad 在同一 Ad Group 内不能新建第二条,也不能删除,但它的**素材字段是可以更新的**。 +每改一次素材就重建 Ad Group 没有必要,而且会留下删不掉的广告。 + +查看广告真实挂载的素材,附带槽位占用和画幅覆盖度: + +```bash +gads ads assets 111222333444 +``` + +`ad_group_ad_asset_view` **不是**真相来源:它会保留历史关联,数量可能超过广告实际挂载的素材。 +这两个命令读的是 `app_ad.*`。 + +素材字段是整字段替换:对 `app_ad.images` 用 `update_mask` 更新时,payload 里没列出的素材会被直接丢弃。 +`set-assets` 会先读取当前素材,再把你的增删应用上去: + +```bash +gads ads set-assets 111222333444 --add-video 555000111222 --remove-video 555000333444 +gads ads set-assets 111222333444 --add-video 555000111222 --validate-only +gads ads set-assets 111222333444 --add-video 555000111222 --execute +``` + +`--set-image`、`--set-video`、`--set-headline`、`--set-description` 用于整体替换某个列表。 +超出每 Ad Group 上限、素材重复、移除广告上没有的素材、把视觉素材清空——这些都会在请求发出前被拦下。 + +每次改动都会触发一次广告审核,所以请把素材变更攒成一次调用。 + +## 查看余额与可投天数 + +```bash +gads billing show +gads billing show --tax-rate 0.06 +``` + +`account_budget` 返回的是**扣税后的可投净额**。预付充值在后台显示的是含税金额,到这里已经除过当地税率, +所以真实可投天数比后台数字看起来的短——`--tax-rate` 会额外打印一列含税等价金额用于对账。 +可投天数默认按启用中 Campaign 的日预算之和估算,可用 `--daily-budget` 覆盖。 + +账户赠金("花 X 送 X")**完全不在 API 里**,只能在后台查看。 + +## 查看变更历史 + +```bash +gads changes list --days 14 +gads changes list --days 7 --resource-type CAMPAIGN_BUDGET +gads changes list --campaign-id 123456789 --limit 500 +``` + +`change_event` 只保留 30 天,且要求闭区间时间窗和 `LIMIT`;这个命令会自动补齐这三项。 + ## 使用通用 mutation 清单 常见操作都有专用命令。对于 `GoogleAdsService.Mutate` 支持的其他资源,可以使用带版本的 @@ -434,6 +488,30 @@ Google 授权、developer token 访问级别或账号权限。 用于正式账号前请阅读 [SECURITY.md](SECURITY.md)。账号权限、政策合规、广告花费,以及每条 带 `--execute` 的命令,最终都由操作者负责。 +## 防止真实账号数据进入公共仓库 + +本仓库是公开的,**提交进去的值即使之后删掉也会永远留在 git 历史里**,所以真正起作用的关卡在提交之前,不是 CI: + +```bash +cp .private-values.example .private-values # 填入你自己账号的真实值 +uv run pre-commit install +``` + +`.private-values` 已被 gitignore,永远不要提交。 + +示例、测试、文档一律只能用合成标识符——包括客户/系列/广告组/广告/素材 ID、账户预算与结算 ID、 +付款账号、余额、邮箱、在投广告文案、内部素材命名。 + +`detect-secrets` 在这里帮不上忙:它认的是凭证,不是业务标识符——真实客户 ID 在它眼里只是十位数字。 +`scripts/check_identifiers.py` 用两条规则补这个缺口: + +- **黑名单**:`.private-values` 里的值一旦出现就失败。精确,但只能抓到你想得起来写下的值。 +- **白名单**:所有 8 位以上数字、分组 ID、邮箱都必须在 `.identifier-allowlist.txt` 里。 + **这条才是抓住"你根本没意识到它是真值"的那一层。** + +往白名单加一行是刻意设计成需要评审的动作——那一行正是有人该问"这个值是真的吗"的地方。 +只有明显是假的值才能进白名单;来自真实账号的数字应该替换掉,而不是加进白名单。 + ## API 兼容性 `0.1.0` 默认使用 Google Ads API `v25` 和官方 Python 客户端 `31.x`。运行 diff --git a/pyproject.toml b/pyproject.toml index 4f517f0..07ed98d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "google-ads-cli" -version = "0.1.0" +version = "0.2.0" description = "A safe, agent-friendly command-line interface for the Google Ads API." readme = "README.md" requires-python = ">=3.11" @@ -41,6 +41,7 @@ gads = "google_ads_cli.cli:main" [dependency-groups] dev = [ "detect-secrets>=1.5,<2", + "pre-commit>=4.6.1", "pytest>=9.1.1,<10", "pytest-cov>=6,<8", "ruff>=0.11,<1", diff --git a/scripts/check_identifiers.py b/scripts/check_identifiers.py new file mode 100644 index 0000000..3a335c9 --- /dev/null +++ b/scripts/check_identifiers.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Block real account identifiers from reaching a public repository. + +`detect-secrets` finds credentials — API keys, tokens, private keys. It has no +concept of a *business* identifier, so a real Google Ads customer ID, ad ID, +account balance, or live ad headline sails straight through: to a credential +scanner they are just digits and prose. + +This check closes that gap with two complementary rules: + +1. **Denylist** — anything listed in `.private-values` (gitignored, never + committed) fails immediately. Precise, no false positives, but only catches + values someone remembered to write down. + +2. **Allowlist** — every long digit run and every email address must appear in + `.identifier-allowlist.txt`. This is the rule that catches identifiers nobody + knew to add to the denylist yet, which is the case that actually leaks. + +Rule 2 is deliberately noisy: adding a genuinely synthetic value costs one +reviewable line in the allowlist, and that line is exactly where a reviewer gets +to ask "is this real?". + +Usage: + python scripts/check_identifiers.py [FILE ...] + +With no arguments every tracked file is scanned. pre-commit passes staged files. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +ALLOWLIST = REPO / ".identifier-allowlist.txt" +PRIVATE_VALUES = REPO / ".private-values" + +# Files that legitimately contain identifier-shaped noise, or define the rules. +SKIP_FILES = { + ".identifier-allowlist.txt", + ".private-values", + ".private-values.example", + ".secrets.baseline", + "uv.lock", + "scripts/check_identifiers.py", +} +SKIP_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".whl", ".mp4"} + +# 8+ consecutive digits: Google Ads customer/campaign/ad group/ad/asset IDs and +# micros amounts all land here. +DIGIT_RUN = re.compile(r"\d{8,}") +EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +# Google payments account / profile IDs, and anything card-shaped. +GROUPED_ID = re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{4}\b") +# Python numeric separators must not hide an identifier: 2_830_190_000. +NUMERIC_SEPARATOR = re.compile(r"(?<=\d)_(?=\d)") + + +def load_lines(path: Path) -> set[str]: + if not path.exists(): + return set() + return { + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + +def tracked_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files"], cwd=REPO, capture_output=True, text=True, check=True + ) + return [line for line in result.stdout.splitlines() if line] + + +def scan(paths: list[str]) -> list[str]: + allowed = load_lines(ALLOWLIST) + private = load_lines(PRIVATE_VALUES) + problems: list[str] = [] + + for rel in paths: + if rel in SKIP_FILES or Path(rel).suffix.lower() in SKIP_SUFFIXES: + continue + path = REPO / rel + if not path.is_file(): + continue + try: + raw = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + + for line_no, line in enumerate(raw.splitlines(), start=1): + for value in sorted(private): + if value and value in line: + problems.append( + f"{rel}:{line_no}: real value from .private-values -> {value!r}\n" + f" Replace it with a synthetic placeholder." + ) + normalized = NUMERIC_SEPARATOR.sub("", line) + for match in DIGIT_RUN.finditer(normalized): + value = match.group() + if value not in allowed: + problems.append( + f"{rel}:{line_no}: unapproved long number -> {value}\n" + f" If it is synthetic, add it to .identifier-allowlist.txt. " + f"If it came from a real account, replace it." + ) + for match in GROUPED_ID.finditer(line): + if match.group() not in allowed: + problems.append( + f"{rel}:{line_no}: grouped ID (payments-account shaped) " + f"-> {match.group()}\n" + f" Replace it unless it is synthetic and allowlisted." + ) + for match in EMAIL.finditer(line): + if match.group() not in allowed: + problems.append( + f"{rel}:{line_no}: unapproved email -> {match.group()}\n" + f" Add it to .identifier-allowlist.txt if it is meant to be public." + ) + return problems + + +def main() -> int: + paths = sys.argv[1:] or tracked_files() + problems = scan(paths) + if not problems: + return 0 + print("Real-identifier check failed:\n", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print( + f"\n{len(problems)} problem(s). This repository is public: a value committed here " + "stays in git history even after a later fix.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/google-ads/references/operations.md b/skills/google-ads/references/operations.md index 8719d0f..c5f250b 100644 --- a/skills/google-ads/references/operations.md +++ b/skills/google-ads/references/operations.md @@ -7,6 +7,8 @@ - Manage campaigns and budgets - Create App Campaigns - Manage creative assets +- Edit App Ad assets in place +- Check funding and change history - Inspect conversions and targeting constants - Use generic mutation manifests - Diagnose failures @@ -23,6 +25,7 @@ gads --format json adgroups list gads --format json ads list gads --format json assets list gads --format json conversions list +gads --format json billing show ``` Narrow ad groups or ads with `--campaign-id`; include removed resources only when auditing @@ -37,6 +40,8 @@ gads reports list gads --format json reports run campaigns --date-range LAST_30_DAYS gads --format csv reports run daily --date-range 2026-07-01:2026-07-28 gads --format json reports run conversion-actions --date-range LAST_30_DAYS +gads --format csv reports run assets --date-range LAST_14_DAYS +gads --format json reports run network --date-range LAST_30_DAYS ``` Available date names include `TODAY`, `YESTERDAY`, `LAST_7_DAYS`, `LAST_14_DAYS`, @@ -148,6 +153,50 @@ gads assets create-youtube VIDEO_ID --name "US Demo 15s" Pass the YouTube video ID, not a full URL. Assets are immutable after upload. +## Edit App Ad assets in place + +An App Ad cannot be duplicated in its ad group or removed, but **its asset fields are +mutable**. Do not rebuild the ad group for a creative change; that leaves undeletable ads +behind. + +```bash +gads --format json ads assets 111222333444 +``` + +Read the ad's real assets plus orientation coverage. `ad_group_ad_asset_view` keeps +historical associations and can report more assets than the ad carries, so it is not the +source of truth — `ad_group_ad.ad.app_ad.*` is. + +```bash +gads ads set-assets 111222333444 --add-video ASSET_ID --remove-image ASSET_ID +gads ads set-assets 111222333444 --add-video ASSET_ID --validate-only +gads ads set-assets 111222333444 --add-video ASSET_ID --execute +``` + +Asset fields are whole-field replacements; this command reads current state and applies +the delta, so nothing is dropped by omission. `--set-image`, `--set-video`, +`--set-headline`, and `--set-description` replace a whole list. Each write triggers an ad +review, so batch creative edits into one call. + +## Check funding and change history + +```bash +gads --format json billing show --tax-rate 0.06 +gads --format json changes list --days 14 --resource-type CAMPAIGN_BUDGET +``` + +`account_budget` reports the **net** spendable amount: a prepay top-up shown as a gross +figure in the UI arrives already divided by the local tax rate, so runway is shorter than +the UI suggests. `--tax-rate` prints the gross-equivalent for reconciliation. + +`change_event` retains 30 days and requires both a bounded window and a `LIMIT`; the +command supplies both. + +**Not available through the API** — use the web UI: promotional account credits, +SKAdNetwork reports, and Google's per-orientation Ad Strength breakdown for App ads +(`asset_group.asset_coverage` is Performance Max only; `ad_group_ad.ad_strength` is a +scalar that stays empty on new ads for a while). + ## Inspect conversions and targeting constants Before choosing an in-app action or value goal: diff --git a/src/google_ads_cli/__init__.py b/src/google_ads_cli/__init__.py index b1e1c32..efeaaeb 100644 --- a/src/google_ads_cli/__init__.py +++ b/src/google_ads_cli/__init__.py @@ -1,3 +1,3 @@ """Safe, agent-friendly Google Ads command-line tooling.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/google_ads_cli/appads.py b/src/google_ads_cli/appads.py new file mode 100644 index 0000000..2e74ceb --- /dev/null +++ b/src/google_ads_cli/appads.py @@ -0,0 +1,363 @@ +"""App Ad asset inspection and safe in-place asset edits. + +Two things this module exists for: + +1. **`ad_group_ad_asset_view` is not the source of truth.** It keeps historical + associations, so it can report more assets than the ad actually carries. + Read `ad_group_ad.ad.app_ad.*` instead — that is what these queries do. + +2. **App Ad asset fields are whole-field replacements.** Updating + `app_ad.images` with an `update_mask` replaces the entire list, so any asset + omitted from the payload is silently dropped. `plan_app_ad_assets` builds the + full replacement from the ad's current state plus an explicit delta, which + removes that footgun. + +The App Ad itself cannot be created a second time in one ad group, nor removed. +Editing its assets in place is therefore the only non-destructive iteration path. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from google_ads_cli.errors import CliError +from google_ads_cli.mutations import MutationOperation, MutationPlan + +# Per-ad-group caps for App campaigns (Google Ads limits). +LIMITS = {"headlines": 5, "descriptions": 5, "images": 20, "youtube_videos": 20} + +APP_AD_QUERY = """ +SELECT + campaign.id, + campaign.name, + ad_group.id, + ad_group.name, + ad_group_ad.ad.id, + ad_group_ad.ad.name, + ad_group_ad.status, + ad_group_ad.ad_strength, + ad_group_ad.policy_summary.approval_status, + ad_group_ad.policy_summary.review_status, + ad_group_ad.ad.app_ad.headlines, + ad_group_ad.ad.app_ad.descriptions, + ad_group_ad.ad.app_ad.images, + ad_group_ad.ad.app_ad.youtube_videos +FROM ad_group_ad +WHERE ad_group_ad.ad.id = {ad_id} +""" + +ASSET_DETAIL_QUERY = """ +SELECT + asset.id, + asset.name, + asset.type, + asset.image_asset.full_size.width_pixels, + asset.image_asset.full_size.height_pixels, + asset.youtube_video_asset.youtube_video_id, + asset.youtube_video_asset.youtube_video_title +FROM asset +WHERE asset.id IN ({asset_ids}) +""" + +# Explicit aspect-ratio tokens are checked before English orientation words: +# creative names routinely use "portrait" or "landscape" to mean the *subject* +# (a portrait shoot, a landscape scene), which would otherwise win over the real +# ratio in a name like "V09_Portrait_16x9". Landscape ratios are checked before +# square so that "1.91x1" is not read as "1x1". +_RATIO_TOKENS = ( + ("LANDSCAPE", (r"1\.91[x:_-]1", r"16[x:_-]9")), + ("PORTRAIT", (r"9[x:_-]16", r"4[x:_-]5", r"2[x:_-]3")), + ("SQUARE", (r"(? str: + """Accept either a bare numeric asset ID or a full asset resource name.""" + text = str(value).strip() + if text.isdigit(): + return text + match = re.fullmatch(r"customers/\d+/assets/(\d+)", text) + if match: + return match.group(1) + raise CliError(f"`{value}` is not an asset ID or an asset resource name.") + + +def parse_app_ad(row: dict[str, Any]) -> AppAdAssets: + ad = ((row.get("ad_group_ad") or {}).get("ad")) or {} + app_ad = ad.get("app_ad") or {} + if not app_ad: + raise CliError( + f"Ad {ad.get('id', '?')} is not an App Ad (no app_ad payload). " + "These commands only apply to App campaign ads." + ) + return AppAdAssets( + ad_id=str(ad.get("id") or ""), + headlines=[item.get("text", "") for item in app_ad.get("headlines", [])], + descriptions=[item.get("text", "") for item in app_ad.get("descriptions", [])], + images=[ + asset_id_from(item["asset"]) for item in app_ad.get("images", []) if "asset" in item + ], + youtube_videos=[ + asset_id_from(item["asset"]) + for item in app_ad.get("youtube_videos", []) + if "asset" in item + ], + ) + + +def image_orientation(width: Any, height: Any) -> str: + try: + ratio = int(width) / int(height) + except (TypeError, ValueError, ZeroDivisionError): + return "UNKNOWN" + if ratio >= 1.2: + return "LANDSCAPE" + if ratio >= 0.9: + return "SQUARE" + return "PORTRAIT" + + +def infer_video_orientation(name: str | None) -> str: + """Guess a YouTube asset's orientation from its name. + + The Google Ads API does not expose a video's aspect ratio, so this is a + naming-convention heuristic. Results are always labelled as inferred. + + An explicit ratio anywhere in the name wins; bare words like "portrait" are + only a fallback, because they are just as often describing the subject. + """ + text = (name or "").lower() + for tokens in (_RATIO_TOKENS, _WORD_TOKENS): + for orientation, patterns in tokens: + for pattern in patterns: + if re.search(pattern, text): + return orientation + return "UNKNOWN" + + +def describe_assets(assets: AppAdAssets, details: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Join the ad's asset IDs against asset metadata, adding orientation.""" + by_id = {str((row.get("asset") or {}).get("id")): row.get("asset") or {} for row in details} + rows: list[dict[str, Any]] = [] + for index, text in enumerate(assets.headlines, start=1): + rows.append({"slot": f"headline {index}", "kind": "HEADLINE", "value": text}) + for index, text in enumerate(assets.descriptions, start=1): + rows.append({"slot": f"description {index}", "kind": "DESCRIPTION", "value": text}) + for asset_id in assets.images: + detail = by_id.get(asset_id, {}) + size = (detail.get("image_asset") or {}).get("full_size") or {} + width, height = size.get("width_pixels"), size.get("height_pixels") + rows.append( + { + "slot": "image", + "kind": "IMAGE", + "asset_id": asset_id, + "name": detail.get("name"), + "dimensions": f"{width}x{height}" if width and height else None, + "orientation": image_orientation(width, height), + "orientation_source": "pixels", + } + ) + for asset_id in assets.youtube_videos: + detail = by_id.get(asset_id, {}) + video = detail.get("youtube_video_asset") or {} + rows.append( + { + "slot": "video", + "kind": "YOUTUBE_VIDEO", + "asset_id": asset_id, + "name": detail.get("name"), + "youtube_video_id": video.get("youtube_video_id"), + "youtube_title": video.get("youtube_video_title"), + "orientation": infer_video_orientation(detail.get("name")), + "orientation_source": "inferred-from-name", + } + ) + return rows + + +def coverage(described: list[dict[str, Any]], ad_strength: str | None) -> dict[str, Any]: + """Slot fill plus per-orientation coverage, with the gaps spelled out.""" + + def _count(kind: str) -> int: + return sum(1 for row in described if row["kind"] == kind) + + def _orientations(kind: str) -> dict[str, int]: + counts: dict[str, int] = {} + for row in described: + if row["kind"] == kind: + counts[row["orientation"]] = counts.get(row["orientation"], 0) + 1 + return counts + + image_orientations = _orientations("IMAGE") + video_orientations = _orientations("YOUTUBE_VIDEO") + gaps: list[str] = [] + for label, counts in (("image", image_orientations), ("video", video_orientations)): + for orientation in ("LANDSCAPE", "SQUARE", "PORTRAIT"): + if not counts.get(orientation): + gaps.append(f"no {orientation.lower()} {label}") + for kind, key in ( + ("HEADLINE", "headlines"), + ("DESCRIPTION", "descriptions"), + ("IMAGE", "images"), + ("YOUTUBE_VIDEO", "youtube_videos"), + ): + used = _count(kind) + if used < LIMITS[key]: + gaps.append(f"{key} {used}/{LIMITS[key]}") + + return { + "ad_strength": ad_strength or "(not yet computed by Google)", + "headlines": f"{_count('HEADLINE')}/{LIMITS['headlines']}", + "descriptions": f"{_count('DESCRIPTION')}/{LIMITS['descriptions']}", + "images": f"{_count('IMAGE')}/{LIMITS['images']}", + "image_orientations": image_orientations, + "videos": f"{_count('YOUTUBE_VIDEO')}/{LIMITS['youtube_videos']}", + "video_orientations": video_orientations, + "gaps": gaps or ["none"], + "note": ( + "Video orientation is inferred from asset names; the API does not expose " + "video aspect ratio. Google's own per-orientation Ad Strength breakdown is " + "only available in the web UI (asset_group.asset_coverage is Performance Max only)." + ), + } + + +def _apply_delta( + current: list[str], + *, + add: tuple[str, ...], + remove: tuple[str, ...], + replace: tuple[str, ...] | None, + label: str, +) -> list[str]: + if replace is not None: + if add or remove: + raise CliError(f"Use either --set-{label} or --add/--remove-{label}, not both.") + result = [asset_id_from(value) for value in replace] + else: + removals = {asset_id_from(value) for value in remove} + unknown = removals - set(current) + if unknown: + raise CliError( + f"Cannot remove {label} asset(s) not on the ad: {', '.join(sorted(unknown))}" + ) + result = [value for value in current if value not in removals] + for value in add: + asset_id = asset_id_from(value) + if asset_id not in result: + result.append(asset_id) + duplicates = {value for value in result if result.count(value) > 1} + if duplicates: + raise CliError(f"Duplicate {label} asset(s): {', '.join(sorted(duplicates))}") + return result + + +def plan_app_ad_assets( + customer_id: str, + current: AppAdAssets, + *, + add_images: tuple[str, ...] = (), + remove_images: tuple[str, ...] = (), + set_images: tuple[str, ...] | None = None, + add_videos: tuple[str, ...] = (), + remove_videos: tuple[str, ...] = (), + set_videos: tuple[str, ...] | None = None, + set_headlines: tuple[str, ...] | None = None, + set_descriptions: tuple[str, ...] | None = None, +) -> tuple[MutationPlan, dict[str, Any]]: + """Build a whole-field replacement from the ad's current state plus a delta.""" + images = _apply_delta( + current.images, add=add_images, remove=remove_images, replace=set_images, label="image" + ) + videos = _apply_delta( + current.youtube_videos, + add=add_videos, + remove=remove_videos, + replace=set_videos, + label="video", + ) + headlines = list(set_headlines) if set_headlines is not None else current.headlines + descriptions = list(set_descriptions) if set_descriptions is not None else current.descriptions + + for values, key in ( + (headlines, "headlines"), + (descriptions, "descriptions"), + (images, "images"), + (videos, "youtube_videos"), + ): + if len(values) > LIMITS[key]: + raise CliError(f"App ads allow at most {LIMITS[key]} {key} ({len(values)} requested).") + if not images and not videos: + raise CliError("Refusing to leave the ad with no image and no video assets.") + if not headlines or not descriptions: + raise CliError("App ads require at least one headline and one description.") + + app_ad: dict[str, Any] = {} + update_mask: list[str] = [] + diff: dict[str, Any] = {} + + if images != current.images: + app_ad["images"] = [{"asset": f"customers/{customer_id}/assets/{i}"} for i in images] + update_mask.append("app_ad.images") + diff["images"] = { + "before": len(current.images), + "after": len(images), + "added": sorted(set(images) - set(current.images)), + "removed": sorted(set(current.images) - set(images)), + } + if videos != current.youtube_videos: + app_ad["youtubeVideos"] = [{"asset": f"customers/{customer_id}/assets/{i}"} for i in videos] + update_mask.append("app_ad.youtube_videos") + diff["youtube_videos"] = { + "before": len(current.youtube_videos), + "after": len(videos), + "added": sorted(set(videos) - set(current.youtube_videos)), + "removed": sorted(set(current.youtube_videos) - set(videos)), + } + if headlines != current.headlines: + app_ad["headlines"] = [{"text": text} for text in headlines] + update_mask.append("app_ad.headlines") + diff["headlines"] = {"before": current.headlines, "after": headlines} + if descriptions != current.descriptions: + app_ad["descriptions"] = [{"text": text} for text in descriptions] + update_mask.append("app_ad.descriptions") + diff["descriptions"] = {"before": current.descriptions, "after": descriptions} + + if not update_mask: + raise CliError("Nothing to change: the requested asset set matches the ad already.") + + plan = MutationPlan( + customer_id=customer_id, + operations=[ + MutationOperation( + resource="ad", + action="update", + data={ + "resourceName": f"customers/{customer_id}/ads/{current.ad_id}", + "appAd": app_ad, + }, + update_mask=update_mask, + ) + ], + label="ads.set-assets", + ) + return plan, diff diff --git a/src/google_ads_cli/billing.py b/src/google_ads_cli/billing.py new file mode 100644 index 0000000..e9f4723 --- /dev/null +++ b/src/google_ads_cli/billing.py @@ -0,0 +1,135 @@ +"""Account funding readouts. + +The Google Ads API exposes account funding through `account_budget` and +`billing_setup`. Both work for prepay accounts, where `account_budget` reports +the *net* spendable amount: a prepay top-up that shows as a gross figure in the +web UI (tax included) arrives here already divided by the local tax rate. + +Promotional credits ("spend X, get X") are **not** exposed by the API at all — +they remain a web-UI-only readout. +""" + +from __future__ import annotations + +from typing import Any + +from google_ads_cli.errors import CliError + +MICROS = 1_000_000 + +ACCOUNT_BUDGET_QUERY = """ +SELECT + account_budget.id, + account_budget.name, + account_budget.status, + account_budget.approved_spending_limit_micros, + account_budget.approved_spending_limit_type, + account_budget.adjusted_spending_limit_micros, + account_budget.amount_served_micros, + account_budget.total_adjustments_micros, + account_budget.approved_start_date_time, + account_budget.approved_end_time_type, + account_budget.billing_setup +FROM account_budget +""" + +BILLING_SETUP_QUERY = """ +SELECT + billing_setup.id, + billing_setup.status, + billing_setup.start_date_time, + billing_setup.end_time_type, + billing_setup.payments_account_info.payments_account_id, + billing_setup.payments_account_info.payments_account_name +FROM billing_setup +""" + +DAILY_BUDGET_QUERY = """ +SELECT + campaign.id, + campaign.name, + campaign.status, + campaign_budget.resource_name, + campaign_budget.amount_micros +FROM campaign +WHERE campaign.status = 'ENABLED' +""" + + +def _micros_to_units(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return int(value) / MICROS + except (TypeError, ValueError): + return None + + +def _budget_field(row: dict[str, Any], name: str) -> Any: + return (row.get("account_budget") or {}).get(name) + + +def summarize_funding( + budget_rows: list[dict[str, Any]], + *, + daily_budget_units: float | None = None, + tax_rate: float | None = None, + currency: str | None = None, +) -> list[dict[str, Any]]: + """Turn raw account_budget rows into a spend-runway readout. + + `tax_rate` (for example 0.06) only adds a *gross-equivalent* column so the + number can be reconciled against the web UI's "Available funds". The API + figures themselves are always reported unchanged. + """ + if tax_rate is not None and not 0 <= tax_rate < 1: + raise CliError("--tax-rate must be between 0 and 1 (for example 0.06 for 6%).") + + summaries: list[dict[str, Any]] = [] + for row in budget_rows: + approved = _micros_to_units(_budget_field(row, "approved_spending_limit_micros")) + adjusted = _micros_to_units(_budget_field(row, "adjusted_spending_limit_micros")) + served = _micros_to_units(_budget_field(row, "amount_served_micros")) or 0.0 + limit = adjusted if adjusted is not None else approved + + summary: dict[str, Any] = { + "account_budget_id": _budget_field(row, "id"), + "status": _budget_field(row, "status"), + "currency": currency, + "spending_limit_net": None if limit is None else round(limit, 2), + "amount_served": round(served, 2), + "remaining_net": None if limit is None else round(limit - served, 2), + "limit_type": _budget_field(row, "approved_spending_limit_type"), + "start": _budget_field(row, "approved_start_date_time"), + "end_type": _budget_field(row, "approved_end_time_type"), + } + + if tax_rate is not None and limit is not None: + summary["spending_limit_gross_equivalent"] = round(limit * (1 + tax_rate), 2) + summary["remaining_gross_equivalent"] = round((limit - served) * (1 + tax_rate), 2) + summary["tax_rate_applied"] = tax_rate + + if daily_budget_units and limit is not None: + remaining = max(limit - served, 0.0) + summary["daily_budget_total"] = round(daily_budget_units, 2) + summary["runway_days"] = round(remaining / daily_budget_units, 1) + + summary["note"] = ( + "account_budget reports NET spendable amount (tax excluded). The web UI's " + "'Available funds' is usually the gross top-up. Promotional credits are not " + "exposed by the API." + ) + summaries.append(summary) + return summaries + + +def total_daily_budget_units(campaign_rows: list[dict[str, Any]]) -> float: + """Sum enabled campaigns' daily budgets, counting each budget resource once.""" + seen: dict[str, float] = {} + for row in campaign_rows: + budget = row.get("campaign_budget") or {} + amount = _micros_to_units(budget.get("amount_micros")) + resource = budget.get("resource_name") or str(id(budget)) + if amount is not None: + seen[resource] = amount + return sum(seen.values()) diff --git a/src/google_ads_cli/changes.py b/src/google_ads_cli/changes.py new file mode 100644 index 0000000..4c0f8ac --- /dev/null +++ b/src/google_ads_cli/changes.py @@ -0,0 +1,99 @@ +"""Account change history. + +`change_event` answers "who changed what, when" — the resource you want when a +campaign starts behaving differently and nobody remembers touching it. + +Two API constraints shape this module: the resource **requires** a `LIMIT` +clause, and it only retains the **last 30 days** of history. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime, timedelta + +from google_ads_cli.errors import CliError + +MAX_LOOKBACK_DAYS = 30 +MAX_LIMIT = 10_000 + +CHANGE_RESOURCE_TYPES = ( + "AD", + "AD_GROUP", + "AD_GROUP_AD", + "AD_GROUP_ASSET", + "AD_GROUP_BID_MODIFIER", + "AD_GROUP_CRITERION", + "AD_GROUP_FEED", + "ASSET", + "ASSET_SET", + "ASSET_SET_ASSET", + "CAMPAIGN", + "CAMPAIGN_ASSET", + "CAMPAIGN_BUDGET", + "CAMPAIGN_CRITERION", + "CAMPAIGN_FEED", + "CUSTOMER_ASSET", + "FEED", + "FEED_ITEM", +) + +_QUERY = """ +SELECT + change_event.change_date_time, + change_event.change_resource_type, + change_event.change_resource_name, + change_event.resource_change_operation, + change_event.client_type, + change_event.user_email, + change_event.changed_fields, + change_event.campaign, + change_event.ad_group +FROM change_event +WHERE {conditions} +ORDER BY change_event.change_date_time DESC +LIMIT {limit} +""" + + +def render_change_query( + *, + customer_id: str, + days: int, + limit: int, + resource_type: str | None = None, + campaign_id: str | None = None, + now: datetime | None = None, +) -> str: + if not 1 <= days <= MAX_LOOKBACK_DAYS: + raise CliError( + f"change_event only retains {MAX_LOOKBACK_DAYS} days of history; " + f"--days must be 1-{MAX_LOOKBACK_DAYS}." + ) + if not 1 <= limit <= MAX_LIMIT: + raise CliError(f"--limit must be between 1 and {MAX_LIMIT}.") + + # change_event rejects an open-ended window ("infinite range"), so both + # bounds are always supplied. The upper bound is nudged into the future so + # changes made seconds ago are still included. + until = (now or datetime.now(UTC)) + timedelta(days=1) + since = until - timedelta(days=days + 1) + conditions = [ + f"change_event.change_date_time >= '{since.strftime('%Y-%m-%d %H:%M:%S')}'", + f"change_event.change_date_time <= '{until.strftime('%Y-%m-%d %H:%M:%S')}'", + ] + if resource_type: + normalized = resource_type.upper() + if normalized not in CHANGE_RESOURCE_TYPES: + raise CliError( + f"Unknown resource type `{resource_type}`. " + f"Choose: {', '.join(CHANGE_RESOURCE_TYPES)}" + ) + conditions.append(f"change_event.change_resource_type = '{normalized}'") + if campaign_id: + if not re.fullmatch(r"\d+", campaign_id): + raise CliError("Campaign ID must be numeric.") + conditions.append( + f"change_event.campaign = 'customers/{customer_id}/campaigns/{campaign_id}'" + ) + return " ".join(_QUERY.format(conditions=" AND ".join(conditions), limit=limit).split()) diff --git a/src/google_ads_cli/cli.py b/src/google_ads_cli/cli.py index a9b3e1d..f668d3a 100644 --- a/src/google_ads_cli/cli.py +++ b/src/google_ads_cli/cli.py @@ -27,8 +27,24 @@ supported_api_versions, ) from google_ads_cli.app_campaign import GOAL_SETTINGS, AppCampaignSpec, build_app_campaign_plan +from google_ads_cli.appads import ( + APP_AD_QUERY, + ASSET_DETAIL_QUERY, + coverage, + describe_assets, + parse_app_ad, + plan_app_ad_assets, +) from google_ads_cli.assets import image_upload_plan, youtube_asset_plan from google_ads_cli.audit import default_audit_path, read_audit +from google_ads_cli.billing import ( + ACCOUNT_BUDGET_QUERY, + BILLING_SETUP_QUERY, + DAILY_BUDGET_QUERY, + summarize_funding, + total_daily_budget_units, +) +from google_ads_cli.changes import CHANGE_RESOURCE_TYPES, render_change_query from google_ads_cli.config import ( AppConfig, Profile, @@ -81,6 +97,8 @@ geo_app = typer.Typer(no_args_is_help=True, help="Find location and language constants.") mutate_app = typer.Typer(no_args_is_help=True, help="Plan, validate, or execute generic mutates.") audit_app = typer.Typer(no_args_is_help=True, help="Inspect the local mutation audit trail.") +billing_app = typer.Typer(no_args_is_help=True, help="Inspect account funding and spend runway.") +changes_app = typer.Typer(no_args_is_help=True, help="Inspect account change history.") app.add_typer(auth_app, name="auth") app.add_typer(config_app, name="config") @@ -97,6 +115,8 @@ app.add_typer(geo_app, name="geo") app.add_typer(mutate_app, name="mutate") app.add_typer(audit_app, name="audit") +app.add_typer(billing_app, name="billing") +app.add_typer(changes_app, name="changes") def _version_callback(value: bool) -> None: @@ -842,6 +862,164 @@ def ads_list( _output(ctx).render(run_gaql(session, query), title="Ads", columns=selected_fields(query)) +def _load_app_ad(ctx: typer.Context, ad_id: str): + if not ad_id.isdigit(): + raise CliError("Ad ID must be numeric.") + session = create_session(_runtime(ctx)) + rows = run_gaql(session, " ".join(APP_AD_QUERY.format(ad_id=ad_id).split())) + if not rows: + raise CliError(f"No ad found with ID {ad_id} in this account.") + return session, rows[0], parse_app_ad(rows[0]) + + +@ads_app.command("assets") +def ads_assets( + ctx: typer.Context, + ad_id: str = typer.Argument(..., help="App Ad ID."), + show_coverage: bool = typer.Option( + True, "--coverage/--no-coverage", help="Summarize slot fill and orientation coverage." + ), +) -> None: + """List the assets an App Ad actually carries, straight from `app_ad.*`. + + This is the source of truth. `ad_group_ad_asset_view` keeps historical + associations and can report more assets than the ad really has. + """ + session, row, assets = _load_app_ad(ctx, ad_id) + asset_ids = assets.images + assets.youtube_videos + details: list[dict[str, Any]] = [] + if asset_ids: + details = run_gaql( + session, + " ".join(ASSET_DETAIL_QUERY.format(asset_ids=", ".join(asset_ids)).split()), + ) + described = describe_assets(assets, details) + writer = _output(ctx) + if show_coverage: + strength = (row.get("ad_group_ad") or {}).get("ad_strength") + writer.render(coverage(described, strength), title=f"Ad {ad_id} · coverage") + writer.render(described, title=f"Ad {ad_id} · assets") + + +@ads_app.command("set-assets") +def ads_set_assets( + ctx: typer.Context, + ad_id: str = typer.Argument(..., help="App Ad ID."), + add_image: list[str] = typer.Option([], "--add-image", help="Asset ID to add."), + remove_image: list[str] = typer.Option([], "--remove-image", help="Asset ID to drop."), + set_image: list[str] | None = typer.Option(None, "--set-image", help="Replace the whole list."), + add_video: list[str] = typer.Option([], "--add-video", help="Asset ID to add."), + remove_video: list[str] = typer.Option([], "--remove-video", help="Asset ID to drop."), + set_video: list[str] | None = typer.Option(None, "--set-video", help="Replace the whole list."), + set_headline: list[str] | None = typer.Option( + None, "--set-headline", help="Replace all headlines." + ), + set_description: list[str] | None = typer.Option( + None, "--set-description", help="Replace all descriptions." + ), + execute: bool = typer.Option(False, "--execute"), + validate_only: bool = typer.Option(False, "--validate-only"), +) -> None: + """Add or drop App Ad assets in place, without rebuilding the ad group. + + App Ad asset fields are whole-field replacements: an `update_mask` on + `app_ad.images` overwrites the entire list, so anything left out is dropped. + This command reads the ad's current assets first and applies your delta on + top, so nothing disappears by omission. + """ + _, _, current = _load_app_ad(ctx, ad_id) + customer_id = _runtime(ctx).customer_id() + plan, diff = plan_app_ad_assets( + customer_id, + current, + add_images=tuple(add_image), + remove_images=tuple(remove_image), + set_images=tuple(set_image) if set_image is not None else None, + add_videos=tuple(add_video), + remove_videos=tuple(remove_video), + set_videos=tuple(set_video) if set_video is not None else None, + set_headlines=tuple(set_headline) if set_headline is not None else None, + set_descriptions=tuple(set_description) if set_description is not None else None, + ) + _output(ctx).render(diff, title=f"Ad {ad_id} · pending asset change") + _run_mutation(ctx, plan, execute=execute, validate_only=validate_only) + + +@billing_app.command("show") +def billing_show( + ctx: typer.Context, + tax_rate: float | None = typer.Option( + None, + "--tax-rate", + help="Show a gross-equivalent column, e.g. 0.06 for 6% VAT, to reconcile with the web UI.", + ), + daily_budget: float | None = typer.Option( + None, + "--daily-budget", + help="Daily spend for the runway estimate (default: sum of enabled campaigns).", + ), +) -> None: + """Show account funding, remaining balance, and spend runway. + + `account_budget` reports the NET spendable amount. A prepay top-up shown as + a gross figure in the web UI arrives here already divided by the local tax + rate, so the runway is shorter than the UI number suggests. Promotional + credits are not exposed by the API — check the web UI for those. + """ + session = create_session(_runtime(ctx)) + budget_rows = run_gaql(session, " ".join(ACCOUNT_BUDGET_QUERY.split())) + if not budget_rows: + raise CliError( + "No account_budget rows. The account may not have a completed billing setup yet." + ) + currency_rows = run_gaql(session, "SELECT customer.currency_code FROM customer") + currency = ((currency_rows or [{}])[0].get("customer") or {}).get("currency_code") + + spend = daily_budget + if spend is None: + spend = total_daily_budget_units(run_gaql(session, " ".join(DAILY_BUDGET_QUERY.split()))) + + writer = _output(ctx) + writer.render( + summarize_funding( + budget_rows, + daily_budget_units=spend or None, + tax_rate=tax_rate, + currency=currency, + ), + title="Account funding", + ) + writer.render(run_gaql(session, " ".join(BILLING_SETUP_QUERY.split())), title="Billing setup") + + +@changes_app.command("list") +def changes_list( + ctx: typer.Context, + days: int = typer.Option( + 14, "--days", min=1, max=30, help="Lookback window (API keeps 30 days)." + ), + limit: int = typer.Option(200, "--limit", min=1, help="change_event requires a LIMIT."), + resource_type: str | None = typer.Option( + None, "--resource-type", help=f"One of: {', '.join(CHANGE_RESOURCE_TYPES)}" + ), + campaign_id: str | None = typer.Option(None, "--campaign-id"), +) -> None: + """Show who changed what, and when. Useful when delivery shifts unexpectedly.""" + query = render_change_query( + customer_id=_runtime(ctx).customer_id(), + days=days, + limit=limit, + resource_type=resource_type, + campaign_id=campaign_id, + ) + session = create_session(_runtime(ctx)) + _output(ctx).render( + run_gaql(session, query), + title=f"Change history · last {days} days", + columns=selected_fields(query), + ) + + @assets_app.command("list") def assets_list( ctx: typer.Context, diff --git a/src/google_ads_cli/presets.py b/src/google_ads_cli/presets.py index 7c45657..3c4d79a 100644 --- a/src/google_ads_cli/presets.py +++ b/src/google_ads_cli/presets.py @@ -128,6 +128,61 @@ class ReportPreset: ORDER BY metrics.all_conversions_value DESC """, ), + "assets": ReportPreset( + "assets", + "Per-asset performance labels (Best/Good/Low/Learning/Pending) and delivery.", + """ + SELECT + campaign.name, + ad_group.name, + asset.id, + asset.name, + asset.type, + ad_group_ad_asset_view.field_type, + ad_group_ad_asset_view.performance_label, + ad_group_ad_asset_view.enabled, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions + FROM ad_group_ad_asset_view + WHERE {date_filter} + ORDER BY ad_group_ad_asset_view.field_type, metrics.impressions DESC + """, + ), + "network": ReportPreset( + "network", + "Delivery split by ad network (Search / YouTube / Display / Discover).", + """ + SELECT + campaign.name, + segments.ad_network_type, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions + FROM campaign + WHERE campaign.status != 'REMOVED' AND {date_filter} + ORDER BY metrics.cost_micros DESC + """, + ), + "daily-campaign": ReportPreset( + "daily-campaign", + "Daily trend split by campaign.", + """ + SELECT + segments.date, + campaign.name, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.all_conversions + FROM campaign + WHERE campaign.status != 'REMOVED' AND {date_filter} + ORDER BY segments.date + """, + ), } PREDEFINED_RANGES = { diff --git a/tests/test_appads.py b/tests/test_appads.py new file mode 100644 index 0000000..4d2aadf --- /dev/null +++ b/tests/test_appads.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import pytest + +from google_ads_cli.ads_client import schema_client +from google_ads_cli.appads import ( + AppAdAssets, + asset_id_from, + coverage, + describe_assets, + image_orientation, + infer_video_orientation, + parse_app_ad, + plan_app_ad_assets, +) +from google_ads_cli.errors import CliError +from google_ads_cli.mutations import compile_operations + +CUSTOMER = "1234567890" + + +def _row(images: list[str], videos: list[str]) -> dict: + return { + "ad_group_ad": { + "ad_strength": "EXCELLENT", + "ad": { + "id": "111222333444", + "app_ad": { + "headlines": [{"text": "Example Headline"}], + "descriptions": [{"text": "Example description."}], + "images": [{"asset": f"customers/{CUSTOMER}/assets/{i}"} for i in images], + "youtube_videos": [ + {"asset": f"customers/{CUSTOMER}/assets/{i}"} for i in videos + ], + }, + }, + } + } + + +def _current(images: list[str], videos: list[str]) -> AppAdAssets: + return parse_app_ad(_row(images, videos)) + + +def test_asset_id_accepts_both_forms() -> None: + assert asset_id_from("123") == "123" + assert asset_id_from(f"customers/{CUSTOMER}/assets/456") == "456" + with pytest.raises(CliError, match="not an asset ID"): + asset_id_from("assets/456") + + +def test_parse_app_ad_rejects_non_app_ads() -> None: + with pytest.raises(CliError, match="not an App Ad"): + parse_app_ad({"ad_group_ad": {"ad": {"id": "1", "app_ad": {}}}}) + + +def test_image_orientation_matches_google_buckets() -> None: + assert image_orientation(1200, 628) == "LANDSCAPE" + assert image_orientation(1200, 1200) == "SQUARE" + assert image_orientation(1200, 1500) == "PORTRAIT" + assert image_orientation(None, 0) == "UNKNOWN" + + +def test_video_orientation_is_inferred_from_name() -> None: + assert infer_video_orientation("promo_v01_9x16_11s") == "PORTRAIT" + assert infer_video_orientation("promo_v04_1x1_11s") == "SQUARE" + assert infer_video_orientation("promo_v07_16x9_11s") == "LANDSCAPE" + assert infer_video_orientation("mystery-clip") == "UNKNOWN" + + +def test_explicit_ratio_beats_a_subject_word_in_the_name() -> None: + # Creative names often use "portrait"/"landscape" for the subject, not the + # aspect ratio. An explicit ratio token has to win. + assert infer_video_orientation("promo_v06_portrait_1x1_11s") == "SQUARE" + assert infer_video_orientation("promo_v09_portrait_16x9_11s") == "LANDSCAPE" + assert infer_video_orientation("promo_v03_portrait_9x16_11s") == "PORTRAIT" + # 1.91:1 is a landscape ratio and must not be read as 1:1. + assert infer_video_orientation("banner_1.91x1") == "LANDSCAPE" + # With no ratio anywhere, the word is all we have. + assert infer_video_orientation("hero_landscape_cut") == "LANDSCAPE" + + +def test_coverage_reports_orientation_gaps() -> None: + described = describe_assets( + _current(["1"], ["2"]), + [ + { + "asset": { + "id": "1", + "name": "img", + "image_asset": {"full_size": {"width_pixels": 1200, "height_pixels": 628}}, + } + }, + { + "asset": { + "id": "2", + "name": "clip_9x16", + "youtube_video_asset": {"youtube_video_id": "abc"}, + } + }, + ], + ) + report = coverage(described, "EXCELLENT") + assert report["image_orientations"] == {"LANDSCAPE": 1} + assert report["video_orientations"] == {"PORTRAIT": 1} + assert "no square image" in report["gaps"] + assert "no landscape video" in report["gaps"] + assert report["ad_strength"] == "EXCELLENT" + + +def test_missing_ad_strength_is_labelled_not_blank() -> None: + assert coverage([], None)["ad_strength"].startswith("(not yet") + + +def test_delta_preserves_untouched_assets() -> None: + current = _current(["1", "2", "3"], ["9"]) + plan, diff = plan_app_ad_assets(CUSTOMER, current, add_images=("4",), remove_images=("2",)) + images = plan.operations[0].data["appAd"]["images"] + assert [item["asset"].rsplit("/", 1)[-1] for item in images] == ["1", "3", "4"] + assert diff["images"] == {"before": 3, "after": 3, "added": ["4"], "removed": ["2"]} + # Videos untouched, so they must stay out of the update mask entirely. + assert plan.operations[0].update_mask == ["app_ad.images"] + assert "youtubeVideos" not in plan.operations[0].data["appAd"] + + +def test_plan_is_valid_against_v25_schema() -> None: + current = _current(["1"], ["9"]) + plan, _ = plan_app_ad_assets(CUSTOMER, current, add_videos=("10",)) + compile_operations(schema_client("v25"), plan.operations, api_version="v25") + + +def test_removing_an_absent_asset_is_rejected() -> None: + current = _current(["1"], ["9"]) + with pytest.raises(CliError, match="not on the ad"): + plan_app_ad_assets(CUSTOMER, current, remove_images=("42",)) + + +def test_caps_are_enforced() -> None: + current = _current([str(i) for i in range(20)], ["9"]) + with pytest.raises(CliError, match="at most 20 images"): + plan_app_ad_assets(CUSTOMER, current, add_images=("999",)) + + +def test_refuses_to_strip_all_visual_assets() -> None: + current = _current(["1"], ["9"]) + with pytest.raises(CliError, match="no image and no video"): + plan_app_ad_assets(CUSTOMER, current, set_images=(), set_videos=()) + + +def test_set_and_add_are_mutually_exclusive() -> None: + current = _current(["1"], ["9"]) + with pytest.raises(CliError, match="not both"): + plan_app_ad_assets(CUSTOMER, current, add_images=("2",), set_images=("3",)) + + +def test_noop_change_is_rejected() -> None: + current = _current(["1"], ["9"]) + with pytest.raises(CliError, match="Nothing to change"): + plan_app_ad_assets(CUSTOMER, current, add_images=("1",)) + + +def test_duplicate_assets_are_rejected() -> None: + current = _current(["1"], ["9"]) + with pytest.raises(CliError, match="Duplicate"): + plan_app_ad_assets(CUSTOMER, current, set_images=("2", "2")) diff --git a/tests/test_billing_and_changes.py b/tests/test_billing_and_changes.py new file mode 100644 index 0000000..fc1628f --- /dev/null +++ b/tests/test_billing_and_changes.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from google_ads_cli.billing import summarize_funding, total_daily_budget_units +from google_ads_cli.changes import render_change_query +from google_ads_cli.errors import CliError + + +def _budget(limit_micros: int, served_micros: int | None = None) -> dict: + account_budget = { + "id": "8888888888", + "status": "APPROVED", + "approved_spending_limit_micros": str(limit_micros), + "adjusted_spending_limit_micros": str(limit_micros), + } + if served_micros is not None: + account_budget["amount_served_micros"] = str(served_micros) + return {"account_budget": account_budget} + + +def test_funding_reports_net_amount_and_runway() -> None: + summary = summarize_funding([_budget(1_000_000_000)], daily_budget_units=100.0, currency="XXX")[ + 0 + ] + assert summary["spending_limit_net"] == 1000.0 + assert summary["remaining_net"] == 1000.0 + assert summary["runway_days"] == 10.0 + assert summary["currency"] == "XXX" + + +def test_tax_rate_reconstructs_the_gross_ui_figure() -> None: + # A prepay top-up shows gross in the UI but arrives here net of local tax. + summary = summarize_funding([_budget(1_000_000_000)], tax_rate=0.06)[0] + assert summary["spending_limit_gross_equivalent"] == 1060.0 + assert summary["tax_rate_applied"] == 0.06 + + +def test_served_amount_reduces_remaining_and_runway() -> None: + summary = summarize_funding([_budget(1_000_000_000, 400_000_000)], daily_budget_units=100.0)[0] + assert summary["amount_served"] == 400.0 + assert summary["remaining_net"] == 600.0 + assert summary["runway_days"] == 6.0 + + +def test_tax_rate_is_validated() -> None: + with pytest.raises(CliError, match="between 0 and 1"): + summarize_funding([_budget(1_000_000)], tax_rate=6) + + +def test_missing_limit_does_not_crash() -> None: + summary = summarize_funding([{"account_budget": {"id": "1"}}], daily_budget_units=150.0)[0] + assert summary["spending_limit_net"] is None + assert "runway_days" not in summary + + +def test_shared_budgets_are_counted_once() -> None: + rows = [ + { + "campaign_budget": { + "resource_name": "customers/1/campaignBudgets/7", + "amount_micros": "150000000", + } + }, + { + "campaign_budget": { + "resource_name": "customers/1/campaignBudgets/7", + "amount_micros": "150000000", + } + }, + { + "campaign_budget": { + "resource_name": "customers/1/campaignBudgets/8", + "amount_micros": "50000000", + } + }, + ] + assert total_daily_budget_units(rows) == 200.0 + + +def test_change_query_windows_and_limits() -> None: + now = datetime(2026, 8, 4, 10, 30, 0, tzinfo=UTC) + query = render_change_query(customer_id="123", days=14, limit=50, now=now) + # change_event rejects an open-ended window, so both bounds must be present. + assert "change_event.change_date_time >= '2026-07-21 10:30:00'" in query + assert "change_event.change_date_time <= '2026-08-05 10:30:00'" in query + assert query.rstrip().endswith("LIMIT 50") + + +def test_change_query_filters_are_injection_safe() -> None: + with pytest.raises(CliError, match="Unknown resource type"): + render_change_query(customer_id="123", days=7, limit=10, resource_type="CAMPAIGN; DROP") + with pytest.raises(CliError, match="numeric"): + render_change_query(customer_id="123", days=7, limit=10, campaign_id="1 OR 1=1") + + +def test_change_query_rejects_windows_beyond_retention() -> None: + with pytest.raises(CliError, match="30 days of history"): + render_change_query(customer_id="123", days=45, limit=10) + + +def test_change_query_scopes_campaign_to_customer() -> None: + query = render_change_query(customer_id="1234567890", days=7, limit=10, campaign_id="123456789") + assert "customers/1234567890/campaigns/123456789" in query diff --git a/tests/test_check_identifiers.py b/tests/test_check_identifiers.py new file mode 100644 index 0000000..7f94afd --- /dev/null +++ b/tests/test_check_identifiers.py @@ -0,0 +1,102 @@ +"""Tests for the real-identifier guard. + +This file is scanned by the very check it tests, so **no literal 8+ digit run, +grouped ID, or email address may appear in the source**. Sample values are +assembled from fragments at runtime instead. That constraint is the point: a +test about catching identifiers must not be the thing that publishes one. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location( + "check_identifiers", REPO / "scripts" / "check_identifiers.py" +) +assert SPEC and SPEC.loader +checker = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(checker) + +# Assembled so the literals never appear in this file. All are invented. +DENIED_ID = "9999" + "888877" +UNKNOWN_ID = "7777" + "6666" + "5555" +SEPARATED = "1_234_" + "500_000" +SEPARATED_FLAT = "1234" + "500000" +GROUPED = "1111-2222-" + "3333-4444" +EMAIL = "ops@" + "invented-company.example" +ALLOWED_ID = "1234567890" # already in the repo allowlist + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """Point the checker at a scratch repo with its own allowlist/denylist.""" + monkeypatch.setattr(checker, "REPO", tmp_path) + monkeypatch.setattr(checker, "ALLOWLIST", tmp_path / ".identifier-allowlist.txt") + monkeypatch.setattr(checker, "PRIVATE_VALUES", tmp_path / ".private-values") + (tmp_path / ".identifier-allowlist.txt").write_text(f"{ALLOWED_ID}\n", encoding="utf-8") + (tmp_path / ".private-values").write_text(f"# comment\n{DENIED_ID}\n", encoding="utf-8") + return tmp_path + + +def _write(repo: Path, name: str, body: str) -> str: + (repo / name).write_text(body, encoding="utf-8") + return name + + +def test_allowlisted_placeholder_passes(repo) -> None: + name = _write(repo, "doc.md", f"gads --customer-id {ALLOWED_ID}\n") + assert checker.scan([name]) == [] + + +def test_unknown_long_number_is_flagged(repo) -> None: + # Not in .private-values: this is the rule that catches identifiers nobody + # knew to write down yet, which is the case that actually leaks. + name = _write(repo, "doc.md", f"gads ads assets {UNKNOWN_ID}\n") + problems = checker.scan([name]) + assert len(problems) == 1 + assert UNKNOWN_ID in problems[0] + + +def test_known_private_value_is_flagged_by_both_rules(repo) -> None: + name = _write(repo, "doc.md", f"customer {DENIED_ID}\n") + problems = checker.scan([name]) + assert any(".private-values" in p for p in problems) + assert any("unapproved long number" in p for p in problems) + + +def test_numeric_separators_cannot_hide_an_identifier(repo) -> None: + name = _write(repo, "test_x.py", f"limit = {SEPARATED}\n") + assert any(SEPARATED_FLAT in p for p in checker.scan([name])) + + +def test_grouped_payments_id_is_flagged(repo) -> None: + name = _write(repo, "doc.md", f"payments account {GROUPED}\n") + assert any("grouped ID" in p for p in checker.scan([name])) + + +def test_unapproved_email_is_flagged(repo) -> None: + name = _write(repo, "doc.md", f"contact {EMAIL}\n") + assert any("unapproved email" in p for p in checker.scan([name])) + + +def test_allowlist_and_denylist_files_are_not_scanned(repo) -> None: + assert checker.scan([".identifier-allowlist.txt", ".private-values"]) == [] + + +def test_short_numbers_and_dates_do_not_trip_it(repo) -> None: + name = _write(repo, "CHANGELOG.md", "## [0.2.0] - 2026-08-04\nport 8080, id 1234567\n") + assert checker.scan([name]) == [] + + +def test_binary_files_are_skipped(repo) -> None: + (repo / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n" + DENIED_ID.encode()) + assert checker.scan(["logo.png"]) == [] + + +def test_repository_itself_is_clean() -> None: + """The guard that matters: the committed tree carries no unapproved identifiers.""" + assert checker.scan(checker.tracked_files()) == [] diff --git a/uv.lock b/uv.lock index cac845c..e34d45a 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -364,6 +373,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + [[package]] name = "google-ads" version = "31.2.0" @@ -385,7 +412,7 @@ wheels = [ [[package]] name = "google-ads-cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "google-ads" }, @@ -400,6 +427,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "detect-secrets" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -419,6 +447,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "detect-secrets", specifier = ">=1.5,<2" }, + { name = "pre-commit", specifier = ">=4.6.1" }, { name = "pytest", specifier = ">=9.1.1,<10" }, { name = "pytest-cov", specifier = ">=6,<8" }, { name = "ruff", specifier = ">=0.11,<1" }, @@ -543,6 +572,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/00/73204406228cf989bea6b0fd9fe4702fab49a8a152a0c6f90856dadb6ac7/grpcio_status-1.83.0-py3-none-any.whl", hash = "sha256:f6a838a7c5fb84ae98833ec0ef81ed438c26e11e54b2ddb8e92ad328c861de69", size = 14636, upload-time = "2026-07-23T15:23:49.044Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -582,6 +620,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -703,6 +750,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, +] + [[package]] name = "proto-plus" version = "1.28.2" @@ -799,6 +862,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1015,3 +1090,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, +]