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" }, +]