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/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/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 e4c392a..d002424 100644 --- a/asa_api_cli/main.py +++ b/asa_api_cli/main.py @@ -6,15 +6,18 @@ from asa_api_cli import ( ad_groups, + ads, apps, auth, brand, + budget_orders, campaigns, countries, geo, impression_share, keywords, optimize, + product_pages, reports, translate, ) @@ -31,10 +34,13 @@ 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") +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/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..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: @@ -67,3 +68,27 @@ 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 + + +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