Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 227 additions & 2 deletions asa_api_cli/ads.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,59 @@
"""Ad (creative) read-only CLI commands."""
"""Ad (creative) CLI commands."""

from typing import Annotated, Any

import typer
from asa_api_client.exceptions import AppleSearchAdsError
from asa_api_client.models import AdCreate, AdStatus, AdUpdate, CreativeType

from asa_api_cli.utils import (
EXIT_ERROR,
EXIT_USAGE,
OutputFormat,
confirm_action,
enum_value,
get_client,
handle_api_error,
output_data,
print_error,
print_info,
print_result_panel,
print_success,
print_warning,
spinner,
)

app = typer.Typer(help="View ads (creatives) within ad groups")
app = typer.Typer(help="View and manage ads (creatives) within ad groups")

AD_COLUMNS = ["id", "name", "creative_type", "status", "serving_status", "creative_id"]


def _validate_create(creative_type: CreativeType, product_page_id: str | None) -> None:
"""Validate the creative-type / product-page combination before any API call.

Raises typer.Exit(EXIT_USAGE) with a friendly message on an invalid combination.
"""
if creative_type == CreativeType.CREATIVE_SET:
print_error(
"Unsupported creative type",
"CREATIVE_SET ads require selecting creative set assets, which this command "
"does not support yet. Use CUSTOM_PRODUCT_PAGE or DEFAULT_PRODUCT_PAGE.",
)
raise typer.Exit(EXIT_USAGE)
if creative_type == CreativeType.CUSTOM_PRODUCT_PAGE and not product_page_id:
print_error(
"Missing product page",
"CUSTOM_PRODUCT_PAGE ads require --product-page <id>. Find ids with 'asa product-pages list'.",
)
raise typer.Exit(EXIT_USAGE)
if creative_type == CreativeType.DEFAULT_PRODUCT_PAGE and product_page_id:
print_error(
"Unexpected product page",
"DEFAULT_PRODUCT_PAGE ads must not specify --product-page.",
)
raise typer.Exit(EXIT_USAGE)


@app.command("list")
def list_ads(
campaign_id: Annotated[
Expand Down Expand Up @@ -118,3 +150,196 @@ def get_ad(
except AppleSearchAdsError as e:
handle_api_error(e)
raise typer.Exit(EXIT_ERROR) from None


@app.command("create")
def create_ad(
name: Annotated[
str,
typer.Option("--name", "-n", help="Ad name"),
],
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"),
],
creative_type: Annotated[
CreativeType,
typer.Option("--creative-type", "-t", help="Creative type"),
] = CreativeType.CUSTOM_PRODUCT_PAGE,
product_page_id: Annotated[
str | None,
typer.Option("--product-page", "-p", help="Custom product page ID (required for CUSTOM_PRODUCT_PAGE)"),
] = None,
status: Annotated[
AdStatus,
typer.Option("--status", "-s", help="Initial status"),
] = AdStatus.ENABLED,
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="Preview the ad payload without creating it"),
] = False,
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip the confirmation prompt"),
] = False,
) -> None:
"""Create an ad in an ad group.

CUSTOM_PRODUCT_PAGE ads need a --product-page id (see 'asa product-pages list').
DEFAULT_PRODUCT_PAGE ads use the app's default page and take no product page.

Examples:
asa ads create -c 123 -a 456 -n "Holiday CPP" -p <product-page-id>
asa ads create -c 123 -a 456 -n "Default ad" -t DEFAULT_PRODUCT_PAGE
asa ads create -c 123 -a 456 -n "Preview" -p <id> --dry-run
"""
_validate_create(creative_type, product_page_id)

payload = AdCreate(
name=name,
creative_type=creative_type,
status=status,
product_page_id=product_page_id,
)

preview = {
"Name": name,
"Creative type": enum_value(creative_type),
"Status": enum_value(status),
"Product page": product_page_id or "-",
"Campaign ID": str(campaign_id),
"Ad group ID": str(ad_group_id),
}

if dry_run:
print_info("Dry run - no ad created")
print_result_panel("Ad payload", preview)
return

if not yes and not confirm_action(f"Create ad '{name}' in ad group {ad_group_id}?", default=True):
print_info("Cancelled")
return

client = get_client()

try:
with client:
with spinner("Creating ad..."):
ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.create(payload)

print_success(f"Created ad {ad.id}: {ad.name}")
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),
},
)
except AppleSearchAdsError as e:
handle_api_error(e)
raise typer.Exit(EXIT_ERROR) from None


@app.command("update")
def update_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"),
],
name: Annotated[
str | None,
typer.Option("--name", "-n", help="New ad name"),
] = None,
status: Annotated[
AdStatus | None,
typer.Option("--status", "-s", help="New status (ENABLED or PAUSED)"),
] = None,
) -> None:
"""Update an ad's name and/or status.

At least one of --name or --status must be provided.

Examples:
asa ads update 789 -c 123 -a 456 --status PAUSED
asa ads update 789 -c 123 -a 456 --name "Renamed ad"
"""
if name is None and status is None:
print_error("Nothing to update", "Provide at least one of --name or --status.")
raise typer.Exit(EXIT_USAGE)

payload = AdUpdate(name=name, status=status)

client = get_client()

try:
with client:
with spinner("Updating ad..."):
ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.update(ad_id, payload)

print_success(f"Updated ad {ad.id}")
print_result_panel(
f"Ad {ad.id}",
{
"Name": ad.name,
"Status": enum_value(ad.status),
"Serving status": enum_value(ad.serving_status),
},
)
except AppleSearchAdsError as e:
handle_api_error(e)
raise typer.Exit(EXIT_ERROR) from None


@app.command("delete")
def delete_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"),
],
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip the confirmation prompt"),
] = False,
) -> None:
"""Delete an ad.

Example:
asa ads delete 789 -c 123 -a 456
"""
if not yes and not confirm_action(f"Delete ad {ad_id}? This cannot be undone.", default=False):
print_info("Cancelled")
return

client = get_client()

try:
with client:
with spinner("Deleting ad..."):
client.campaigns(campaign_id).ad_groups(ad_group_id).ads.delete(ad_id)

print_success(f"Deleted ad {ad_id}")
except AppleSearchAdsError as e:
handle_api_error(e)
raise typer.Exit(EXIT_ERROR) from None
2 changes: 1 addition & 1 deletion asa_api_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
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(ads.app, name="ads", help="View and manage 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")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,51 @@ def test_ads_help() -> None:
assert result.exit_code == 0
assert "list" in result.stdout
assert "get" in result.stdout
assert "create" in result.stdout
assert "update" in result.stdout
assert "delete" in result.stdout


def test_ads_create_creative_set_rejected() -> None:
"""CREATIVE_SET is not supported by the create command (no asset selection)."""
result = runner.invoke(
app,
["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "CREATIVE_SET"],
)
assert result.exit_code != 0


def test_ads_create_custom_page_requires_product_page() -> None:
"""CUSTOM_PRODUCT_PAGE without --product-page should fail before any API call."""
result = runner.invoke(
app,
["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "CUSTOM_PRODUCT_PAGE"],
)
assert result.exit_code != 0


def test_ads_create_default_page_rejects_product_page() -> None:
"""DEFAULT_PRODUCT_PAGE must not be given a --product-page."""
result = runner.invoke(
app,
["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "DEFAULT_PRODUCT_PAGE", "-p", "pp1"],
)
assert result.exit_code != 0


def test_ads_create_dry_run_succeeds_offline() -> None:
"""A valid --dry-run create previews the payload and exits 0 without an API call."""
result = runner.invoke(
app,
["ads", "create", "-c", "1", "-a", "2", "-n", "Test ad", "-t", "DEFAULT_PRODUCT_PAGE", "--dry-run"],
)
assert result.exit_code == 0


def test_ads_update_requires_a_field() -> None:
"""update with neither --name nor --status should fail before any API call."""
result = runner.invoke(app, ["ads", "update", "789", "-c", "1", "-a", "2"])
assert result.exit_code != 0


def test_product_pages_help() -> None:
Expand Down
Loading