From ce7c6d93bd75992e3f5befdd196695f7fd1651f8 Mon Sep 17 00:00:00 2001 From: Sam Petherbridge Date: Tue, 9 Jun 2026 13:55:21 +1000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20ads=20and=20product-pages?= =?UTF-8?q?=20view=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface two v5 creative-management resources (read-only): - asa ads list/get — view ads within an ad group - asa product-pages — list/get custom product pages (CPP) and per-locale details (locales) Ads are read-only: v5 ad creation is tied to product pages and creative sets, out of scope for this pass. Phase 2 of #32. --- asa_api_cli/ads.py | 120 +++++++++++++++++++++++++++++ asa_api_cli/main.py | 4 + asa_api_cli/product_pages.py | 143 +++++++++++++++++++++++++++++++++++ tests/test_cli.py | 16 ++++ 4 files changed, 283 insertions(+) create mode 100644 asa_api_cli/ads.py create mode 100644 asa_api_cli/product_pages.py diff --git a/asa_api_cli/ads.py b/asa_api_cli/ads.py new file mode 100644 index 0000000..b49bbe1 --- /dev/null +++ b/asa_api_cli/ads.py @@ -0,0 +1,120 @@ +"""Ad (creative) read-only CLI commands.""" + +from typing import Annotated, Any + +import typer +from asa_api_client.exceptions import AppleSearchAdsError + +from asa_api_cli.utils import ( + EXIT_ERROR, + OutputFormat, + enum_value, + get_client, + handle_api_error, + output_data, + print_result_panel, + print_warning, + spinner, +) + +app = typer.Typer(help="View ads (creatives) within ad groups") + +AD_COLUMNS = ["id", "name", "creative_type", "status", "serving_status", "creative_id"] + + +@app.command("list") +def list_ads( + campaign_id: Annotated[ + int, + typer.Option("--campaign", "-c", help="Campaign ID"), + ], + ad_group_id: Annotated[ + int, + typer.Option("--ad-group", "-a", help="Ad group ID"), + ], + limit: Annotated[ + int, + typer.Option("--limit", "-l", help="Maximum number of results"), + ] = 100, + format: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format"), + ] = OutputFormat.TABLE, +) -> None: + """List ads in an ad group. + + Examples: + asa ads list --campaign 123 --ad-group 456 + asa ads list -c 123 -a 456 --format json + """ + client = get_client() + + try: + with client: + with spinner("Fetching ads..."): + result = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.list(limit=limit) + + if not result.data: + print_warning("No ads found") + return + + rows: list[dict[str, Any]] = [ + { + "id": ad.id, + "name": ad.name, + "creative_type": enum_value(ad.creative_type), + "status": enum_value(ad.status), + "serving_status": enum_value(ad.serving_status), + "creative_id": ad.creative_id or "-", + } + for ad in result.data + ] + + output_data(rows, AD_COLUMNS, format, title=f"Ads in ad group {ad_group_id}") + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("get") +def get_ad( + ad_id: Annotated[ + int, + typer.Argument(help="Ad ID"), + ], + campaign_id: Annotated[ + int, + typer.Option("--campaign", "-c", help="Campaign ID"), + ], + ad_group_id: Annotated[ + int, + typer.Option("--ad-group", "-a", help="Ad group ID"), + ], +) -> None: + """Show details for a single ad. + + Example: + asa ads get 789 --campaign 123 --ad-group 456 + """ + client = get_client() + + try: + with client: + with spinner("Fetching ad..."): + ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.get(ad_id) + + print_result_panel( + f"Ad {ad.id}", + { + "Name": ad.name, + "Creative type": enum_value(ad.creative_type), + "Status": enum_value(ad.status), + "Serving status": enum_value(ad.serving_status), + "Creative ID": str(ad.creative_id) if ad.creative_id else "-", + "Campaign ID": str(ad.campaign_id), + "Ad group ID": str(ad.ad_group_id), + }, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None diff --git a/asa_api_cli/main.py b/asa_api_cli/main.py index e4c392a..5c269fe 100644 --- a/asa_api_cli/main.py +++ b/asa_api_cli/main.py @@ -6,6 +6,7 @@ from asa_api_cli import ( ad_groups, + ads, apps, auth, brand, @@ -15,6 +16,7 @@ impression_share, keywords, optimize, + product_pages, reports, translate, ) @@ -31,7 +33,9 @@ app.add_typer(brand.app, name="brand", help="Create brand protection campaigns") app.add_typer(campaigns.app, name="campaigns", help="Manage campaigns") app.add_typer(ad_groups.app, name="ad-groups", help="Manage ad groups") +app.add_typer(ads.app, name="ads", help="View ads (creatives) within ad groups") app.add_typer(keywords.app, name="keywords", help="Manage keywords") +app.add_typer(product_pages.app, name="product-pages", help="View custom product pages (CPP)") app.add_typer(apps.app, name="apps", help="Search the App Store for advertisable apps") app.add_typer(geo.app, name="geo", help="Search geographic locations for targeting") app.add_typer(countries.app, name="countries", help="List supported countries and regions") diff --git a/asa_api_cli/product_pages.py b/asa_api_cli/product_pages.py new file mode 100644 index 0000000..8a6d42d --- /dev/null +++ b/asa_api_cli/product_pages.py @@ -0,0 +1,143 @@ +"""Custom Product Page (CPP) read-only CLI commands.""" + +from typing import Annotated, Any + +import typer +from asa_api_client.exceptions import AppleSearchAdsError + +from asa_api_cli.utils import ( + EXIT_ERROR, + OutputFormat, + get_client, + handle_api_error, + output_data, + print_result_panel, + print_warning, + spinner, +) + +app = typer.Typer(help="View custom product pages (CPP)") + +PRODUCT_PAGE_COLUMNS = ["id", "name", "adam_id", "state", "deep_link"] +LOCALE_COLUMNS = ["language_code", "language", "app_name", "device_classes"] + + +@app.command("list") +def list_product_pages( + limit: Annotated[ + int, + typer.Option("--limit", "-l", help="Maximum number of results"), + ] = 100, + format: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format"), + ] = OutputFormat.TABLE, +) -> None: + """List custom product pages. + + Examples: + asa product-pages list + asa product-pages list --format json + """ + client = get_client() + + try: + with client: + with spinner("Fetching product pages..."): + result = client.product_pages.list(limit=limit) + + if not result.data: + print_warning("No product pages found") + return + + rows: list[dict[str, Any]] = [ + { + "id": pp.id, + "name": pp.name or "-", + "adam_id": pp.adam_id or "-", + "state": pp.state or "-", + "deep_link": pp.deep_link or "-", + } + for pp in result.data + ] + + output_data(rows, PRODUCT_PAGE_COLUMNS, format, title="Custom product pages") + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("get") +def get_product_page( + product_page_id: Annotated[ + str, + typer.Argument(help="Product page ID"), + ], +) -> None: + """Show details for a single custom product page. + + Example: + asa product-pages get + """ + client = get_client() + + try: + with client: + with spinner("Fetching product page..."): + pp = client.product_pages.get(product_page_id) + + print_result_panel( + f"Product page {pp.id}", + { + "Name": pp.name or "-", + "adam ID": str(pp.adam_id) if pp.adam_id else "-", + "State": pp.state or "-", + "Deep link": pp.deep_link or "-", + }, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("locales") +def product_page_locales( + product_page_id: Annotated[ + str, + typer.Argument(help="Product page ID"), + ], + format: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format"), + ] = OutputFormat.TABLE, +) -> None: + """List the per-locale details of a custom product page. + + Example: + asa product-pages locales + """ + client = get_client() + + try: + with client: + with spinner("Fetching locale details..."): + details = client.product_pages.get_locale_details(product_page_id) + + if not details: + print_warning("No locale details found") + return + + rows: list[dict[str, Any]] = [ + { + "language_code": detail.language_code or "-", + "language": detail.language or "-", + "app_name": detail.app_name or "-", + "device_classes": ", ".join(detail.device_classes or []) or "-", + } + for detail in details + ] + + output_data(rows, LOCALE_COLUMNS, format, title=f"Locales for {product_page_id}") + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None diff --git a/tests/test_cli.py b/tests/test_cli.py index 8374331..0d43c82 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,3 +67,19 @@ def test_countries_help() -> None: result = runner.invoke(app, ["countries", "--help"]) assert result.exit_code == 0 assert "list" in result.stdout + + +def test_ads_help() -> None: + """Test ads subcommand help.""" + result = runner.invoke(app, ["ads", "--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout + assert "get" in result.stdout + + +def test_product_pages_help() -> None: + """Test product-pages subcommand help.""" + result = runner.invoke(app, ["product-pages", "--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout + assert "locales" in result.stdout From a51ada44f8e3b02cf694f90aab6ec8c066c9b12b Mon Sep 17 00:00:00 2001 From: Sam Petherbridge Date: Tue, 9 Jun 2026 13:56:13 +1000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Add=20budget-orders=20view=20co?= =?UTF-8?q?mmands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the v5 budget orders resource: - asa budget-orders list — list budget orders (budget, status, dates) - asa budget-orders get — show a single budget order Note: the generic custom-reports endpoint is intentionally not surfaced — Apple disabled GET /custom-reports (403) in March 2026 in favour of the Insights tool, so the existing impression-share POST/poll flow remains the working path. Phase 3 of #32. --- asa_api_cli/budget_orders.py | 114 +++++++++++++++++++++++++++++++++++ asa_api_cli/main.py | 2 + tests/test_cli.py | 8 +++ 3 files changed, 124 insertions(+) create mode 100644 asa_api_cli/budget_orders.py diff --git a/asa_api_cli/budget_orders.py b/asa_api_cli/budget_orders.py new file mode 100644 index 0000000..4ff9f0b --- /dev/null +++ b/asa_api_cli/budget_orders.py @@ -0,0 +1,114 @@ +"""Budget order CLI commands.""" + +from typing import Annotated, Any + +import typer +from asa_api_client.exceptions import AppleSearchAdsError + +from asa_api_cli.utils import ( + EXIT_ERROR, + OutputFormat, + enum_value, + format_money, + get_client, + handle_api_error, + output_data, + print_result_panel, + print_warning, + spinner, +) + +app = typer.Typer(help="View budget orders") + +BUDGET_ORDER_COLUMNS = ["id", "name", "status", "budget", "order_number", "start_date", "end_date"] + + +def _date_str(value: object) -> str: + """Render a datetime/date-ish value as an ISO date, or '-'.""" + if value is None: + return "-" + return str(value).split("T")[0].split(" ")[0] + + +@app.command("list") +def list_budget_orders( + limit: Annotated[ + int, + typer.Option("--limit", "-l", help="Maximum number of results"), + ] = 100, + format: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format"), + ] = OutputFormat.TABLE, +) -> None: + """List budget orders. + + Examples: + asa budget-orders list + asa budget-orders list --format json + """ + client = get_client() + + try: + with client: + with spinner("Fetching budget orders..."): + result = client.budget_orders.list(limit=limit) + + if not result.data: + print_warning("No budget orders found") + return + + rows: list[dict[str, Any]] = [ + { + "id": bo.id, + "name": bo.name or "-", + "status": enum_value(bo.status) if bo.status else "-", + "budget": format_money(bo.budget.amount, bo.budget.currency) if bo.budget else "-", + "order_number": bo.order_number or "-", + "start_date": _date_str(bo.start_date), + "end_date": _date_str(bo.end_date), + } + for bo in result.data + ] + + output_data(rows, BUDGET_ORDER_COLUMNS, format, title="Budget orders") + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("get") +def get_budget_order( + budget_order_id: Annotated[ + int, + typer.Argument(help="Budget order ID"), + ], +) -> None: + """Show details for a single budget order. + + Example: + asa budget-orders get 123456 + """ + client = get_client() + + try: + with client: + with spinner("Fetching budget order..."): + bo = client.budget_orders.get(budget_order_id) + + print_result_panel( + f"Budget order {bo.id}", + { + "Name": bo.name or "-", + "Status": enum_value(bo.status) if bo.status else "-", + "Budget": format_money(bo.budget.amount, bo.budget.currency) if bo.budget else "-", + "Order number": bo.order_number or "-", + "Start date": _date_str(bo.start_date), + "End date": _date_str(bo.end_date), + "Client name": bo.client_name or "-", + "Billing email": bo.billing_email or "-", + }, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None diff --git a/asa_api_cli/main.py b/asa_api_cli/main.py index 5c269fe..d002424 100644 --- a/asa_api_cli/main.py +++ b/asa_api_cli/main.py @@ -10,6 +10,7 @@ apps, auth, brand, + budget_orders, campaigns, countries, geo, @@ -39,6 +40,7 @@ app.add_typer(apps.app, name="apps", help="Search the App Store for advertisable apps") app.add_typer(geo.app, name="geo", help="Search geographic locations for targeting") app.add_typer(countries.app, name="countries", help="List supported countries and regions") +app.add_typer(budget_orders.app, name="budget-orders", help="View budget orders") app.add_typer(reports.app, name="reports", help="Generate reports") app.add_typer(optimize.app, name="optimize", help="Optimization tools") app.add_typer(impression_share.app, name="impression-share", help="Impression share analysis") diff --git a/tests/test_cli.py b/tests/test_cli.py index 0d43c82..dbaa162 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -83,3 +83,11 @@ def test_product_pages_help() -> None: assert result.exit_code == 0 assert "list" in result.stdout assert "locales" in result.stdout + + +def test_budget_orders_help() -> None: + """Test budget-orders subcommand help.""" + result = runner.invoke(app, ["budget-orders", "--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout + assert "get" in result.stdout From fd1766a32c16233fde7652c2e3404619f3d82ca5 Mon Sep 17 00:00:00 2001 From: Sam Petherbridge Date: Tue, 9 Jun 2026 13:57:23 +1000 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Add=20auth=20orgs=20command=20f?= =?UTF-8?q?or=20org=20access=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the v5 ACL resource via 'asa auth orgs', listing the organizations the configured credentials can access (org ID, name, currency, roles, time zone). Useful for finding the ASA_ORG_ID to use in multi-org setups. Phase 4 of #32. --- asa_api_cli/auth.py | 58 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 1 + 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/asa_api_cli/auth.py b/asa_api_cli/auth.py index 0e688da..e64d8b4 100644 --- a/asa_api_cli/auth.py +++ b/asa_api_cli/auth.py @@ -1,7 +1,7 @@ """Authentication CLI commands.""" from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from asa_api_client import AppleSearchAdsClient, Settings @@ -12,8 +12,12 @@ from asa_api_cli.utils import ( EXIT_ERROR, EXIT_USAGE, + OutputFormat, console, error_console, + get_client, + handle_api_error, + output_data, print_error, print_info, print_result_panel, @@ -200,3 +204,55 @@ def show_config( msg = err["msg"] error_console.print(f" [error]ASA_{str(field).upper()}:[/error] {msg}") raise typer.Exit(EXIT_USAGE) from None + + +ORG_COLUMNS = ["org_id", "org_name", "currency", "role_names", "time_zone"] + + +@app.command("orgs") +def list_orgs( + format: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format"), + ] = OutputFormat.TABLE, +) -> None: + """List organizations your credentials can access. + + Shows each org's ID (use as ASA_ORG_ID), name, currency, and your roles. + + Examples: + asa auth orgs + asa auth orgs --format json + """ + client = get_client() + + try: + with client: + with spinner("Fetching accessible organizations..."): + acls = client.acls.list() + + if not acls: + print_warning("No organizations found for these credentials") + return + + rows: list[dict[str, Any]] = [ + { + "org_id": acl.org_id, + "org_name": acl.org_name, + "currency": acl.currency or "-", + "role_names": ", ".join(acl.role_names) or "-", + "time_zone": acl.time_zone or "-", + } + for acl in acls + ] + + output_data( + rows, + ORG_COLUMNS, + format, + title="Accessible organizations", + column_labels={"org_id": "Org ID", "time_zone": "Time Zone"}, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None diff --git a/tests/test_cli.py b/tests/test_cli.py index dbaa162..f088f6d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,7 @@ def test_auth_help() -> None: assert result.exit_code == 0 assert "show" in result.stdout assert "test" in result.stdout + assert "orgs" in result.stdout def test_root_help_lists_lookup_commands() -> None: