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.
asa_api_cli/reports.py ADDED
@@ -0,0 +1,440 @@
1
+ """Report CLI commands."""
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from asa_api_client.exceptions import AppleSearchAdsError
10
+ from asa_api_client.models import GranularityType
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from asa_api_cli.utils import (
15
+ console,
16
+ format_number,
17
+ format_percent,
18
+ get_client,
19
+ handle_api_error,
20
+ parse_date,
21
+ print_error,
22
+ print_success,
23
+ print_warning,
24
+ spinner,
25
+ )
26
+
27
+ app = typer.Typer(help="Generate performance reports")
28
+
29
+
30
+ def format_report_money(spend: object | None) -> str:
31
+ """Format money value for display."""
32
+ if spend is None:
33
+ return "-"
34
+ return f"{spend.amount} {spend.currency}" # type: ignore
35
+
36
+
37
+ def report_row_to_dict(row: object) -> dict[str, Any]:
38
+ """Convert report row to display dictionary."""
39
+ result: dict[str, Any] = {}
40
+
41
+ # Metadata
42
+ if row.metadata: # type: ignore
43
+ meta = row.metadata # type: ignore
44
+ if meta.campaign_name:
45
+ result["campaign"] = meta.campaign_name
46
+ if meta.ad_group_name:
47
+ result["ad_group"] = meta.ad_group_name
48
+ if meta.keyword:
49
+ result["keyword"] = meta.keyword
50
+ if meta.search_term_text:
51
+ result["search_term"] = meta.search_term_text
52
+ if meta.country_or_region:
53
+ result["country"] = meta.country_or_region
54
+
55
+ # Metrics
56
+ if row.total: # type: ignore
57
+ total = row.total # type: ignore
58
+ result["impressions"] = total.impressions
59
+ result["taps"] = total.taps
60
+ result["installs"] = total.installs
61
+ result["ttr"] = total.ttr
62
+ result["conv_rate"] = total.conversion_rate
63
+ result["spend"] = total.local_spend.amount if total.local_spend else None
64
+ result["avg_cpt"] = total.avg_cpt.amount if total.avg_cpt else None
65
+ result["avg_cpa"] = total.avg_cpa.amount if total.avg_cpa else None
66
+
67
+ return result
68
+
69
+
70
+ def print_report_table(
71
+ data: list[dict[str, Any]],
72
+ columns: list[str],
73
+ title: str,
74
+ ) -> None:
75
+ """Print report data as a styled table."""
76
+ table = Table(
77
+ title=title,
78
+ show_header=True,
79
+ header_style="header",
80
+ border_style="muted",
81
+ row_styles=["", "dim"],
82
+ )
83
+
84
+ # Column headers
85
+ column_labels = {
86
+ "campaign": "Campaign",
87
+ "ad_group": "Ad Group",
88
+ "keyword": "Keyword",
89
+ "search_term": "Search Term",
90
+ "country": "Country",
91
+ "impressions": "Impressions",
92
+ "taps": "Taps",
93
+ "installs": "Installs",
94
+ "ttr": "TTR",
95
+ "conv_rate": "Conv Rate",
96
+ "spend": "Spend",
97
+ "avg_cpt": "Avg CPT",
98
+ "avg_cpa": "Avg CPA",
99
+ }
100
+
101
+ for col in columns:
102
+ table.add_column(column_labels.get(col, col))
103
+
104
+ for row in data:
105
+ values = []
106
+ for col in columns:
107
+ value = row.get(col)
108
+ if col in ("ttr", "conv_rate"):
109
+ values.append(format_percent(value))
110
+ elif col in ("impressions", "taps", "installs"):
111
+ values.append(format_number(value))
112
+ elif col in ("spend", "avg_cpt", "avg_cpa"):
113
+ values.append(str(value) if value else "-")
114
+ else:
115
+ values.append(str(value) if value else "-")
116
+ table.add_row(*values)
117
+
118
+ console.print(table)
119
+
120
+
121
+ def print_grand_totals(report: object) -> None:
122
+ """Print grand totals in a styled panel."""
123
+ if not report.grand_totals or not report.grand_totals.total: # type: ignore
124
+ return
125
+
126
+ total = report.grand_totals.total # type: ignore
127
+
128
+ lines = []
129
+ lines.append(f"[label]Impressions:[/label] [value]{format_number(total.impressions)}[/value]")
130
+ lines.append(f"[label]Taps:[/label] [value]{format_number(total.taps)}[/value]")
131
+ lines.append(f"[label]Installs:[/label] [value]{format_number(total.installs)}[/value]")
132
+
133
+ if total.ttr is not None:
134
+ lines.append(f"[label]TTR:[/label] [value]{format_percent(total.ttr)}[/value]")
135
+ if total.conversion_rate is not None:
136
+ lines.append(f"[label]Conv Rate:[/label] [value]{format_percent(total.conversion_rate)}[/value]")
137
+ if total.local_spend:
138
+ spend = total.local_spend
139
+ lines.append(f"[label]Total Spend:[/label] [value]{spend.amount} {spend.currency}[/value]")
140
+
141
+ panel = Panel(
142
+ "\n".join(lines),
143
+ title="[info]Grand Totals[/info]",
144
+ border_style="cyan",
145
+ padding=(0, 1),
146
+ )
147
+ console.print()
148
+ console.print(panel)
149
+
150
+
151
+ def save_report(
152
+ data: list[dict[str, Any]],
153
+ output: Path,
154
+ columns: list[str],
155
+ ) -> None:
156
+ """Save report to file."""
157
+ suffix = output.suffix.lower()
158
+
159
+ if suffix == ".json":
160
+ output.write_text(json.dumps(data, indent=2, default=str))
161
+ elif suffix == ".csv":
162
+ with output.open("w", newline="") as f:
163
+ writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
164
+ writer.writeheader()
165
+ writer.writerows(data)
166
+ else:
167
+ print_error("Unsupported Format", f"File format '{suffix}' is not supported", "Supported formats: .json, .csv")
168
+ raise typer.Exit(1)
169
+
170
+ print_success(f"Report saved to {output}")
171
+
172
+
173
+ @app.command("campaigns")
174
+ def campaign_report(
175
+ start: Annotated[
176
+ str,
177
+ typer.Option("--start", "-s", help="Start date (YYYY-MM-DD)"),
178
+ ],
179
+ end: Annotated[
180
+ str,
181
+ typer.Option("--end", "-e", help="End date (YYYY-MM-DD)"),
182
+ ],
183
+ campaign_ids: Annotated[
184
+ list[int] | None,
185
+ typer.Option("--campaign", "-c", help="Filter by campaign ID(s)"),
186
+ ] = None,
187
+ granularity: Annotated[
188
+ GranularityType,
189
+ typer.Option("--granularity", "-g", help="Time granularity"),
190
+ ] = GranularityType.DAILY,
191
+ output: Annotated[
192
+ Path | None,
193
+ typer.Option("--output", "-o", help="Save to file (JSON or CSV)"),
194
+ ] = None,
195
+ ) -> None:
196
+ """Generate a campaign performance report.
197
+
198
+ Examples:
199
+ asa reports campaigns --start 2024-01-01 --end 2024-01-31
200
+ asa reports campaigns -s 2024-01-01 -e 2024-01-31 --campaign 123
201
+ asa reports campaigns -s 2024-01-01 -e 2024-01-31 -o report.csv
202
+ asa reports campaigns -s 2024-01-01 -e 2024-01-31 --granularity MONTHLY
203
+ """
204
+ start_date = parse_date(start)
205
+ end_date = parse_date(end)
206
+
207
+ client = get_client()
208
+
209
+ try:
210
+ with client:
211
+ with spinner("Generating campaign report..."):
212
+ report = client.reports.campaigns(
213
+ start_date=start_date,
214
+ end_date=end_date,
215
+ campaign_ids=campaign_ids,
216
+ granularity=granularity,
217
+ )
218
+
219
+ if not report.row:
220
+ print_warning("No data found for the specified period")
221
+ return
222
+
223
+ data = [report_row_to_dict(row) for row in report.row]
224
+ columns = ["campaign", "impressions", "taps", "installs", "ttr", "conv_rate", "spend"]
225
+
226
+ if output:
227
+ save_report(data, output, columns)
228
+ else:
229
+ print_report_table(
230
+ data,
231
+ columns,
232
+ f"Campaign Report ({start} to {end})",
233
+ )
234
+ print_grand_totals(report)
235
+
236
+ except AppleSearchAdsError as e:
237
+ handle_api_error(e)
238
+ raise typer.Exit(1) from None
239
+
240
+
241
+ @app.command("ad-groups")
242
+ def ad_group_report(
243
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
244
+ start: Annotated[
245
+ str,
246
+ typer.Option("--start", "-s", help="Start date (YYYY-MM-DD)"),
247
+ ],
248
+ end: Annotated[
249
+ str,
250
+ typer.Option("--end", "-e", help="End date (YYYY-MM-DD)"),
251
+ ],
252
+ granularity: Annotated[
253
+ GranularityType,
254
+ typer.Option("--granularity", "-g", help="Time granularity"),
255
+ ] = GranularityType.DAILY,
256
+ output: Annotated[
257
+ Path | None,
258
+ typer.Option("--output", "-o", help="Save to file (JSON or CSV)"),
259
+ ] = None,
260
+ ) -> None:
261
+ """Generate an ad group performance report.
262
+
263
+ Examples:
264
+ asa reports ad-groups 123 --start 2024-01-01 --end 2024-01-31
265
+ asa reports ad-groups 123 -s 2024-01-01 -e 2024-01-31 -o report.csv
266
+ """
267
+ start_date = parse_date(start)
268
+ end_date = parse_date(end)
269
+
270
+ client = get_client()
271
+
272
+ try:
273
+ with client:
274
+ with spinner("Generating ad group report..."):
275
+ report = client.reports.ad_groups(
276
+ campaign_id=campaign_id,
277
+ start_date=start_date,
278
+ end_date=end_date,
279
+ granularity=granularity,
280
+ )
281
+
282
+ if not report.row:
283
+ print_warning("No data found for the specified period")
284
+ return
285
+
286
+ data = [report_row_to_dict(row) for row in report.row]
287
+ columns = ["ad_group", "impressions", "taps", "installs", "ttr", "conv_rate", "spend"]
288
+
289
+ if output:
290
+ save_report(data, output, columns)
291
+ else:
292
+ print_report_table(
293
+ data,
294
+ columns,
295
+ f"Ad Group Report ({start} to {end})",
296
+ )
297
+ print_grand_totals(report)
298
+
299
+ except AppleSearchAdsError as e:
300
+ handle_api_error(e)
301
+ raise typer.Exit(1) from None
302
+
303
+
304
+ @app.command("keywords")
305
+ def keyword_report(
306
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
307
+ start: Annotated[
308
+ str,
309
+ typer.Option("--start", "-s", help="Start date (YYYY-MM-DD)"),
310
+ ],
311
+ end: Annotated[
312
+ str,
313
+ typer.Option("--end", "-e", help="End date (YYYY-MM-DD)"),
314
+ ],
315
+ ad_group_ids: Annotated[
316
+ list[int] | None,
317
+ typer.Option("--ad-group", "-a", help="Filter by ad group ID(s)"),
318
+ ] = None,
319
+ granularity: Annotated[
320
+ GranularityType,
321
+ typer.Option("--granularity", "-g", help="Time granularity"),
322
+ ] = GranularityType.DAILY,
323
+ output: Annotated[
324
+ Path | None,
325
+ typer.Option("--output", "-o", help="Save to file (JSON or CSV)"),
326
+ ] = None,
327
+ ) -> None:
328
+ """Generate a keyword performance report.
329
+
330
+ Examples:
331
+ asa reports keywords 123 --start 2024-01-01 --end 2024-01-31
332
+ asa reports keywords 123 -s 2024-01-01 -e 2024-01-31 --ad-group 456
333
+ asa reports keywords 123 -s 2024-01-01 -e 2024-01-31 -o keywords.csv
334
+ """
335
+ start_date = parse_date(start)
336
+ end_date = parse_date(end)
337
+
338
+ client = get_client()
339
+
340
+ try:
341
+ with client:
342
+ with spinner("Generating keyword report..."):
343
+ report = client.reports.keywords(
344
+ campaign_id=campaign_id,
345
+ start_date=start_date,
346
+ end_date=end_date,
347
+ ad_group_ids=ad_group_ids,
348
+ granularity=granularity,
349
+ )
350
+
351
+ if not report.row:
352
+ print_warning("No data found for the specified period")
353
+ return
354
+
355
+ data = [report_row_to_dict(row) for row in report.row]
356
+ columns = ["keyword", "impressions", "taps", "installs", "ttr", "conv_rate", "spend", "avg_cpt"]
357
+
358
+ if output:
359
+ save_report(data, output, columns)
360
+ else:
361
+ print_report_table(
362
+ data,
363
+ columns,
364
+ f"Keyword Report ({start} to {end})",
365
+ )
366
+ print_grand_totals(report)
367
+
368
+ except AppleSearchAdsError as e:
369
+ handle_api_error(e)
370
+ raise typer.Exit(1) from None
371
+
372
+
373
+ @app.command("search-terms")
374
+ def search_term_report(
375
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
376
+ start: Annotated[
377
+ str,
378
+ typer.Option("--start", "-s", help="Start date (YYYY-MM-DD)"),
379
+ ],
380
+ end: Annotated[
381
+ str,
382
+ typer.Option("--end", "-e", help="End date (YYYY-MM-DD)"),
383
+ ],
384
+ ad_group_id: Annotated[
385
+ int | None,
386
+ typer.Option("--ad-group", "-a", help="Filter by ad group ID"),
387
+ ] = None,
388
+ granularity: Annotated[
389
+ GranularityType,
390
+ typer.Option("--granularity", "-g", help="Time granularity"),
391
+ ] = GranularityType.DAILY,
392
+ output: Annotated[
393
+ Path | None,
394
+ typer.Option("--output", "-o", help="Save to file (JSON or CSV)"),
395
+ ] = None,
396
+ ) -> None:
397
+ """Generate a search term performance report.
398
+
399
+ Shows actual search queries that triggered your ads.
400
+
401
+ Examples:
402
+ asa reports search-terms 123 --start 2024-01-01 --end 2024-01-31
403
+ asa reports search-terms 123 -s 2024-01-01 -e 2024-01-31 -o terms.csv
404
+ """
405
+ start_date = parse_date(start)
406
+ end_date = parse_date(end)
407
+
408
+ client = get_client()
409
+
410
+ try:
411
+ with client:
412
+ with spinner("Generating search term report..."):
413
+ report = client.reports.search_terms(
414
+ campaign_id=campaign_id,
415
+ start_date=start_date,
416
+ end_date=end_date,
417
+ ad_group_id=ad_group_id,
418
+ granularity=granularity,
419
+ )
420
+
421
+ if not report.row:
422
+ print_warning("No data found for the specified period")
423
+ return
424
+
425
+ data = [report_row_to_dict(row) for row in report.row]
426
+ columns = ["search_term", "impressions", "taps", "installs", "ttr", "conv_rate", "spend"]
427
+
428
+ if output:
429
+ save_report(data, output, columns)
430
+ else:
431
+ print_report_table(
432
+ data,
433
+ columns,
434
+ f"Search Term Report ({start} to {end})",
435
+ )
436
+ print_grand_totals(report)
437
+
438
+ except AppleSearchAdsError as e:
439
+ handle_api_error(e)
440
+ raise typer.Exit(1) from None