asa-api-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ """Campaign CLI commands."""
2
+
3
+ from datetime import date, timedelta
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+ from asa_api_client.exceptions import AppleSearchAdsError
8
+ from asa_api_client.models import CampaignStatus, CampaignUpdate, Money, Selector
9
+
10
+ from asa_api_cli.utils import (
11
+ OutputFormat,
12
+ confirm_action,
13
+ enum_value,
14
+ format_money,
15
+ get_client,
16
+ handle_api_error,
17
+ output_data,
18
+ print_json,
19
+ print_result_panel,
20
+ print_success,
21
+ print_warning,
22
+ spinner,
23
+ )
24
+
25
+ app = typer.Typer(help="Manage campaigns")
26
+
27
+ CAMPAIGN_COLUMNS = [
28
+ "id",
29
+ "name",
30
+ "status",
31
+ "serving_status",
32
+ "daily_budget",
33
+ "countries",
34
+ ]
35
+
36
+ CAMPAIGN_COLUMNS_WITH_SPEND = [
37
+ "id",
38
+ "name",
39
+ "status",
40
+ "serving_status",
41
+ "daily_budget",
42
+ "spend_7d",
43
+ "countries",
44
+ ]
45
+
46
+ CAMPAIGN_COLUMN_LABELS = {
47
+ "id": "ID",
48
+ "serving_status": "Serving",
49
+ "daily_budget": "Daily Budget",
50
+ "spend_7d": "Spend (7d)",
51
+ }
52
+
53
+
54
+ def _colorize_status(status: str) -> str:
55
+ """Add color to status values."""
56
+ if status == "ENABLED":
57
+ return "[green]ENABLED[/green]"
58
+ elif status == "PAUSED":
59
+ return "[yellow]PAUSED[/yellow]"
60
+ return status
61
+
62
+
63
+ def _colorize_serving(serving: str) -> str:
64
+ """Add color to serving status values."""
65
+ if serving == "RUNNING":
66
+ return "[green]RUNNING[/green]"
67
+ elif serving == "NOT_RUNNING":
68
+ return "[dim]NOT_RUNNING[/dim]"
69
+ return serving
70
+
71
+
72
+ def campaign_to_dict(campaign: object, spend: str | None = None, colorize: bool = False) -> dict[str, Any]:
73
+ """Convert campaign to display dictionary."""
74
+ status = enum_value(campaign.status) # type: ignore
75
+ serving_status = enum_value(campaign.serving_status) # type: ignore
76
+
77
+ if colorize:
78
+ status = _colorize_status(status)
79
+ serving_status = _colorize_serving(serving_status)
80
+
81
+ result = {
82
+ "id": campaign.id, # type: ignore
83
+ "name": campaign.name, # type: ignore
84
+ "status": status,
85
+ "serving_status": serving_status,
86
+ "daily_budget": format_money(
87
+ campaign.daily_budget_amount.amount if campaign.daily_budget_amount else None, # type: ignore
88
+ campaign.daily_budget_amount.currency if campaign.daily_budget_amount else None, # type: ignore
89
+ ),
90
+ "countries": ", ".join(campaign.countries_or_regions[:3]) # type: ignore
91
+ + ("..." if len(campaign.countries_or_regions) > 3 else ""), # type: ignore
92
+ }
93
+ if spend is not None:
94
+ result["spend_7d"] = spend
95
+ return result
96
+
97
+
98
+ @app.command("list")
99
+ def list_campaigns(
100
+ status: Annotated[
101
+ CampaignStatus | None,
102
+ typer.Option("--status", "-s", help="Filter by status (ENABLED or PAUSED)"),
103
+ ] = None,
104
+ all_campaigns: Annotated[
105
+ bool,
106
+ typer.Option("--all", "-a", help="Show all campaigns including paused (default: enabled only)"),
107
+ ] = False,
108
+ with_spend: Annotated[
109
+ bool,
110
+ typer.Option("--with-spend", "-w", help="Include 7-day spend (slower - requires report API)"),
111
+ ] = False,
112
+ limit: Annotated[
113
+ int,
114
+ typer.Option("--limit", "-l", help="Maximum number of results"),
115
+ ] = 100,
116
+ format: Annotated[
117
+ OutputFormat,
118
+ typer.Option("--format", "-f", help="Output format"),
119
+ ] = OutputFormat.TABLE,
120
+ ) -> None:
121
+ """List campaigns.
122
+
123
+ By default, shows only ENABLED campaigns. Use --all to see paused campaigns too.
124
+
125
+ Examples:
126
+ asa campaigns list # Show enabled campaigns
127
+ asa campaigns list --all # Show all campaigns
128
+ asa campaigns list --status PAUSED # Show only paused campaigns
129
+ asa campaigns list --with-spend # Include 7-day spend data
130
+ asa campaigns list --format json
131
+ """
132
+ client = get_client()
133
+
134
+ try:
135
+ with client:
136
+ # Determine which status to filter by
137
+ filter_status = status
138
+ if filter_status is None and not all_campaigns:
139
+ filter_status = CampaignStatus.ENABLED
140
+
141
+ with spinner("Fetching campaigns..."):
142
+ if filter_status:
143
+ selector = Selector().where("status", "==", filter_status.value).limit(limit)
144
+ campaigns = client.campaigns.find(selector)
145
+ else:
146
+ campaigns = client.campaigns.list(limit=limit)
147
+
148
+ if not campaigns.data:
149
+ print_warning("No campaigns found")
150
+ return
151
+
152
+ # Get spend data if requested
153
+ spend_by_campaign: dict[int, str] = {}
154
+ if with_spend:
155
+ with spinner("Fetching 7-day spend data..."):
156
+ end = date.today()
157
+ start = end - timedelta(days=7)
158
+ report = client.reports.campaigns(start, end)
159
+
160
+ for row in report.row or []:
161
+ if row.metadata and row.metadata.campaign_id and row.total:
162
+ spend = row.total.local_spend
163
+ if spend:
164
+ spend_by_campaign[row.metadata.campaign_id] = f"{spend.amount} {spend.currency}"
165
+
166
+ # Use colors for table output
167
+ use_colors = format == OutputFormat.TABLE
168
+
169
+ data = [
170
+ campaign_to_dict(
171
+ c,
172
+ spend=spend_by_campaign.get(c.id),
173
+ colorize=use_colors,
174
+ )
175
+ for c in campaigns
176
+ ]
177
+
178
+ # Choose columns based on whether spend is included
179
+ columns = CAMPAIGN_COLUMNS_WITH_SPEND if with_spend else CAMPAIGN_COLUMNS
180
+
181
+ # Build title
182
+ status_label = filter_status.value if filter_status else "all"
183
+ title = f"Campaigns ({campaigns.total_results} {status_label})"
184
+
185
+ output_data(
186
+ data,
187
+ columns,
188
+ format,
189
+ title=title,
190
+ column_labels=CAMPAIGN_COLUMN_LABELS,
191
+ )
192
+
193
+ except AppleSearchAdsError as e:
194
+ handle_api_error(e)
195
+ raise typer.Exit(1) from None
196
+
197
+
198
+ @app.command("get")
199
+ def get_campaign(
200
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
201
+ format: Annotated[
202
+ OutputFormat,
203
+ typer.Option("--format", "-f", help="Output format"),
204
+ ] = OutputFormat.JSON,
205
+ ) -> None:
206
+ """Get details for a specific campaign.
207
+
208
+ Examples:
209
+ asa campaigns get 123456789
210
+ asa campaigns get 123456789 --format table
211
+ """
212
+ client = get_client()
213
+
214
+ try:
215
+ with client:
216
+ with spinner("Fetching campaign..."):
217
+ campaign = client.campaigns.get(campaign_id)
218
+
219
+ if format == OutputFormat.JSON:
220
+ print_json(campaign, title=f"Campaign {campaign_id}")
221
+ else:
222
+ data = [campaign_to_dict(campaign)]
223
+ output_data(data, CAMPAIGN_COLUMNS, format, column_labels=CAMPAIGN_COLUMN_LABELS)
224
+
225
+ except AppleSearchAdsError as e:
226
+ handle_api_error(e)
227
+ raise typer.Exit(1) from None
228
+
229
+
230
+ @app.command("pause")
231
+ def pause_campaign(
232
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID to pause")],
233
+ ) -> None:
234
+ """Pause a campaign.
235
+
236
+ Examples:
237
+ asa campaigns pause 123456789
238
+ """
239
+ client = get_client()
240
+
241
+ try:
242
+ with client:
243
+ with spinner("Pausing campaign..."):
244
+ campaign = client.campaigns.update(
245
+ campaign_id,
246
+ data=CampaignUpdate(status=CampaignStatus.PAUSED),
247
+ )
248
+ print_success(f"Campaign '{campaign.name}' paused")
249
+
250
+ except AppleSearchAdsError as e:
251
+ handle_api_error(e)
252
+ raise typer.Exit(1) from None
253
+
254
+
255
+ @app.command("enable")
256
+ def enable_campaign(
257
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID to enable")],
258
+ ) -> None:
259
+ """Enable a paused campaign.
260
+
261
+ Examples:
262
+ asa campaigns enable 123456789
263
+ """
264
+ client = get_client()
265
+
266
+ try:
267
+ with client:
268
+ with spinner("Enabling campaign..."):
269
+ campaign = client.campaigns.update(
270
+ campaign_id,
271
+ data=CampaignUpdate(status=CampaignStatus.ENABLED),
272
+ )
273
+ print_success(f"Campaign '{campaign.name}' enabled")
274
+
275
+ except AppleSearchAdsError as e:
276
+ handle_api_error(e)
277
+ raise typer.Exit(1) from None
278
+
279
+
280
+ @app.command("set-budget")
281
+ def set_budget(
282
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
283
+ daily_budget: Annotated[
284
+ float | None,
285
+ typer.Option("--daily", "-d", help="Daily budget amount"),
286
+ ] = None,
287
+ total_budget: Annotated[
288
+ float | None,
289
+ typer.Option("--total", "-t", help="Total budget amount"),
290
+ ] = None,
291
+ currency: Annotated[
292
+ str,
293
+ typer.Option("--currency", "-c", help="Currency code"),
294
+ ] = "USD",
295
+ ) -> None:
296
+ """Update campaign budget.
297
+
298
+ Examples:
299
+ asa campaigns set-budget 123456789 --daily 100
300
+ asa campaigns set-budget 123456789 --daily 100 --total 10000
301
+ asa campaigns set-budget 123456789 --daily 100 --currency EUR
302
+ """
303
+ if daily_budget is None and total_budget is None:
304
+ print_warning("Specify at least --daily or --total budget")
305
+ raise typer.Exit(1)
306
+
307
+ client = get_client()
308
+
309
+ try:
310
+ with client:
311
+ update = CampaignUpdate()
312
+ if daily_budget is not None:
313
+ update.daily_budget_amount = Money(amount=str(daily_budget), currency=currency)
314
+ if total_budget is not None:
315
+ update.budget_amount = Money(amount=str(total_budget), currency=currency)
316
+
317
+ with spinner("Updating budget..."):
318
+ campaign = client.campaigns.update(campaign_id, data=update)
319
+
320
+ result_data = {"Campaign": campaign.name}
321
+ if campaign.daily_budget_amount:
322
+ amt = campaign.daily_budget_amount
323
+ result_data["Daily Budget"] = f"{amt.amount} {amt.currency}"
324
+ if campaign.budget_amount:
325
+ amt = campaign.budget_amount
326
+ result_data["Total Budget"] = f"{amt.amount} {amt.currency}"
327
+
328
+ print_result_panel("Budget Updated", result_data)
329
+
330
+ except AppleSearchAdsError as e:
331
+ handle_api_error(e)
332
+ raise typer.Exit(1) from None
333
+
334
+
335
+ @app.command("delete")
336
+ def delete_campaign(
337
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID to delete")],
338
+ force: Annotated[
339
+ bool,
340
+ typer.Option("--force", "-f", help="Skip confirmation"),
341
+ ] = False,
342
+ ) -> None:
343
+ """Delete a campaign.
344
+
345
+ WARNING: This action cannot be undone.
346
+
347
+ Examples:
348
+ asa campaigns delete 123456789
349
+ asa campaigns delete 123456789 --force
350
+ """
351
+ client = get_client()
352
+
353
+ try:
354
+ with client:
355
+ # Get campaign name for confirmation
356
+ with spinner("Fetching campaign..."):
357
+ campaign = client.campaigns.get(campaign_id)
358
+
359
+ if not force:
360
+ if not confirm_action(f"Are you sure you want to delete campaign '{campaign.name}'?"):
361
+ print_warning("Cancelled")
362
+ raise typer.Exit(0)
363
+
364
+ with spinner("Deleting campaign..."):
365
+ client.campaigns.delete(campaign_id)
366
+
367
+ print_success(f"Campaign '{campaign.name}' deleted")
368
+
369
+ except AppleSearchAdsError as e:
370
+ handle_api_error(e)
371
+ raise typer.Exit(1) from None