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,514 @@
1
+ """Keyword CLI commands."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+ from asa_api_client.exceptions import AppleSearchAdsError
7
+ from asa_api_client.models import (
8
+ KeywordCreate,
9
+ KeywordMatchType,
10
+ KeywordStatus,
11
+ KeywordUpdate,
12
+ Money,
13
+ NegativeKeywordCreate,
14
+ Selector,
15
+ )
16
+
17
+ from asa_api_cli.utils import (
18
+ OutputFormat,
19
+ confirm_action,
20
+ enum_value,
21
+ format_money,
22
+ get_client,
23
+ handle_api_error,
24
+ output_data,
25
+ print_json,
26
+ print_result_panel,
27
+ print_success,
28
+ print_warning,
29
+ spinner,
30
+ )
31
+
32
+ app = typer.Typer(help="Manage keywords")
33
+
34
+ KEYWORD_COLUMNS = [
35
+ "id",
36
+ "text",
37
+ "match_type",
38
+ "status",
39
+ "bid",
40
+ ]
41
+
42
+ KEYWORD_COLUMN_LABELS = {
43
+ "id": "ID",
44
+ "match_type": "Match Type",
45
+ }
46
+
47
+
48
+ def keyword_to_dict(keyword: object) -> dict[str, Any]:
49
+ """Convert keyword to display dictionary."""
50
+ return {
51
+ "id": keyword.id, # type: ignore
52
+ "text": keyword.text, # type: ignore
53
+ "match_type": enum_value(keyword.match_type), # type: ignore
54
+ "status": enum_value(keyword.status), # type: ignore
55
+ "bid": format_money(
56
+ keyword.bid_amount.amount if keyword.bid_amount else None, # type: ignore
57
+ keyword.bid_amount.currency if keyword.bid_amount else None, # type: ignore
58
+ ),
59
+ }
60
+
61
+
62
+ @app.command("list")
63
+ def list_keywords(
64
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
65
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
66
+ status: Annotated[
67
+ KeywordStatus | None,
68
+ typer.Option("--status", "-s", help="Filter by status"),
69
+ ] = None,
70
+ match_type: Annotated[
71
+ KeywordMatchType | None,
72
+ typer.Option("--match-type", "-m", help="Filter by match type"),
73
+ ] = None,
74
+ limit: Annotated[
75
+ int,
76
+ typer.Option("--limit", "-l", help="Maximum number of results"),
77
+ ] = 100,
78
+ format: Annotated[
79
+ OutputFormat,
80
+ typer.Option("--format", "-f", help="Output format"),
81
+ ] = OutputFormat.TABLE,
82
+ ) -> None:
83
+ """List targeting keywords in an ad group.
84
+
85
+ Examples:
86
+ asa keywords list 123 456
87
+ asa keywords list 123 456 --status ACTIVE
88
+ asa keywords list 123 456 --match-type EXACT
89
+ asa keywords list 123 456 --format json
90
+ """
91
+ client = get_client()
92
+
93
+ try:
94
+ with client:
95
+ with spinner("Fetching keywords..."):
96
+ selector = Selector().limit(limit)
97
+
98
+ if status:
99
+ selector = selector.where("status", "==", status.value)
100
+ if match_type:
101
+ selector = selector.where("matchType", "==", match_type.value)
102
+
103
+ if selector.conditions:
104
+ keywords = client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.find(selector)
105
+ else:
106
+ keywords = client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.list(limit=limit)
107
+
108
+ if not keywords.data:
109
+ print_warning("No keywords found")
110
+ return
111
+
112
+ data = [keyword_to_dict(k) for k in keywords]
113
+ output_data(
114
+ data,
115
+ KEYWORD_COLUMNS,
116
+ format,
117
+ title=f"Keywords ({keywords.total_results} total)",
118
+ column_labels=KEYWORD_COLUMN_LABELS,
119
+ )
120
+
121
+ except AppleSearchAdsError as e:
122
+ handle_api_error(e)
123
+ raise typer.Exit(1) from None
124
+
125
+
126
+ @app.command("get")
127
+ def get_keyword(
128
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
129
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
130
+ keyword_id: Annotated[int, typer.Argument(help="Keyword ID")],
131
+ format: Annotated[
132
+ OutputFormat,
133
+ typer.Option("--format", "-f", help="Output format"),
134
+ ] = OutputFormat.JSON,
135
+ ) -> None:
136
+ """Get details for a specific keyword.
137
+
138
+ Examples:
139
+ asa keywords get 123 456 789
140
+ """
141
+ client = get_client()
142
+
143
+ try:
144
+ with client:
145
+ with spinner("Fetching keyword..."):
146
+ keyword = client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.get(keyword_id)
147
+
148
+ if format == OutputFormat.JSON:
149
+ print_json(keyword, title=f"Keyword {keyword_id}")
150
+ else:
151
+ data = [keyword_to_dict(keyword)]
152
+ output_data(data, KEYWORD_COLUMNS, format, column_labels=KEYWORD_COLUMN_LABELS)
153
+
154
+ except AppleSearchAdsError as e:
155
+ handle_api_error(e)
156
+ raise typer.Exit(1) from None
157
+
158
+
159
+ @app.command("add")
160
+ def add_keyword(
161
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
162
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
163
+ text: Annotated[str, typer.Argument(help="Keyword text")],
164
+ match_type: Annotated[
165
+ KeywordMatchType,
166
+ typer.Option("--match-type", "-m", help="Match type"),
167
+ ] = KeywordMatchType.EXACT,
168
+ bid: Annotated[
169
+ float | None,
170
+ typer.Option("--bid", "-b", help="Bid amount (uses ad group default if not specified)"),
171
+ ] = None,
172
+ currency: Annotated[
173
+ str,
174
+ typer.Option("--currency", "-c", help="Currency code"),
175
+ ] = "USD",
176
+ ) -> None:
177
+ """Add a targeting keyword.
178
+
179
+ Examples:
180
+ asa keywords add 123 456 "productivity app"
181
+ asa keywords add 123 456 "todo list" --match-type BROAD
182
+ asa keywords add 123 456 "task manager" --bid 2.50
183
+ """
184
+ client = get_client()
185
+
186
+ try:
187
+ with client:
188
+ create_data = KeywordCreate(
189
+ text=text,
190
+ match_type=match_type,
191
+ )
192
+ if bid is not None:
193
+ create_data.bid_amount = Money(amount=str(bid), currency=currency)
194
+
195
+ with spinner("Adding keyword..."):
196
+ keyword = client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.create(create_data)
197
+
198
+ print_result_panel(
199
+ "Keyword Added",
200
+ {
201
+ "ID": str(keyword.id),
202
+ "Text": keyword.text,
203
+ "Match Type": keyword.match_type.value,
204
+ },
205
+ )
206
+
207
+ except AppleSearchAdsError as e:
208
+ handle_api_error(e)
209
+ raise typer.Exit(1) from None
210
+
211
+
212
+ @app.command("pause")
213
+ def pause_keyword(
214
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
215
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
216
+ keyword_id: Annotated[int, typer.Argument(help="Keyword ID to pause")],
217
+ ) -> None:
218
+ """Pause a keyword.
219
+
220
+ Examples:
221
+ asa keywords pause 123 456 789
222
+ """
223
+ client = get_client()
224
+
225
+ try:
226
+ with client:
227
+ with spinner("Pausing keyword..."):
228
+ keyword = (
229
+ client.campaigns(campaign_id)
230
+ .ad_groups(ad_group_id)
231
+ .keywords.update(keyword_id, data=KeywordUpdate(status=KeywordStatus.PAUSED))
232
+ )
233
+ print_success(f"Keyword '{keyword.text}' paused")
234
+
235
+ except AppleSearchAdsError as e:
236
+ handle_api_error(e)
237
+ raise typer.Exit(1) from None
238
+
239
+
240
+ @app.command("enable")
241
+ def enable_keyword(
242
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
243
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
244
+ keyword_id: Annotated[int, typer.Argument(help="Keyword ID to enable")],
245
+ ) -> None:
246
+ """Enable a paused keyword.
247
+
248
+ Examples:
249
+ asa keywords enable 123 456 789
250
+ """
251
+ client = get_client()
252
+
253
+ try:
254
+ with client:
255
+ with spinner("Enabling keyword..."):
256
+ keyword = (
257
+ client.campaigns(campaign_id)
258
+ .ad_groups(ad_group_id)
259
+ .keywords.update(keyword_id, data=KeywordUpdate(status=KeywordStatus.ACTIVE))
260
+ )
261
+ print_success(f"Keyword '{keyword.text}' enabled")
262
+
263
+ except AppleSearchAdsError as e:
264
+ handle_api_error(e)
265
+ raise typer.Exit(1) from None
266
+
267
+
268
+ @app.command("set-bid")
269
+ def set_keyword_bid(
270
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
271
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
272
+ keyword_id: Annotated[int, typer.Argument(help="Keyword ID")],
273
+ bid: Annotated[float, typer.Argument(help="New bid amount")],
274
+ currency: Annotated[
275
+ str,
276
+ typer.Option("--currency", "-c", help="Currency code"),
277
+ ] = "USD",
278
+ ) -> None:
279
+ """Set the bid for a keyword.
280
+
281
+ Examples:
282
+ asa keywords set-bid 123 456 789 3.00
283
+ asa keywords set-bid 123 456 789 2.50 --currency EUR
284
+ """
285
+ client = get_client()
286
+
287
+ try:
288
+ with client:
289
+ with spinner("Updating keyword bid..."):
290
+ keyword = (
291
+ client.campaigns(campaign_id)
292
+ .ad_groups(ad_group_id)
293
+ .keywords.update(
294
+ keyword_id,
295
+ data=KeywordUpdate(bid_amount=Money(amount=str(bid), currency=currency)),
296
+ )
297
+ )
298
+
299
+ print_result_panel(
300
+ "Keyword Bid Updated",
301
+ {
302
+ "Keyword": keyword.text,
303
+ "Bid": f"{keyword.bid_amount.amount} {keyword.bid_amount.currency}",
304
+ },
305
+ )
306
+
307
+ except AppleSearchAdsError as e:
308
+ handle_api_error(e)
309
+ raise typer.Exit(1) from None
310
+
311
+
312
+ @app.command("delete")
313
+ def delete_keyword(
314
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
315
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
316
+ keyword_id: Annotated[int, typer.Argument(help="Keyword ID to delete")],
317
+ force: Annotated[
318
+ bool,
319
+ typer.Option("--force", "-f", help="Skip confirmation"),
320
+ ] = False,
321
+ ) -> None:
322
+ """Delete a keyword.
323
+
324
+ Examples:
325
+ asa keywords delete 123 456 789
326
+ asa keywords delete 123 456 789 --force
327
+ """
328
+ client = get_client()
329
+
330
+ try:
331
+ with client:
332
+ with spinner("Fetching keyword..."):
333
+ keyword = client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.get(keyword_id)
334
+
335
+ if not force:
336
+ if not confirm_action(f"Are you sure you want to delete keyword '{keyword.text}'?"):
337
+ print_warning("Cancelled")
338
+ raise typer.Exit(0)
339
+
340
+ with spinner("Deleting keyword..."):
341
+ client.campaigns(campaign_id).ad_groups(ad_group_id).keywords.delete(keyword_id)
342
+
343
+ print_success(f"Keyword '{keyword.text}' deleted")
344
+
345
+ except AppleSearchAdsError as e:
346
+ handle_api_error(e)
347
+ raise typer.Exit(1) from None
348
+
349
+
350
+ # Negative keywords subcommand
351
+ negatives_app = typer.Typer(help="Manage negative keywords")
352
+ app.add_typer(negatives_app, name="negatives")
353
+
354
+ NEGATIVE_KEYWORD_COLUMNS = ["id", "text", "match_type", "status"]
355
+ NEGATIVE_KEYWORD_COLUMN_LABELS = {
356
+ "id": "ID",
357
+ "match_type": "Match Type",
358
+ }
359
+
360
+
361
+ @negatives_app.command("list")
362
+ def list_negatives(
363
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
364
+ ad_group_id: Annotated[
365
+ int | None,
366
+ typer.Option("--ad-group", "-a", help="Ad Group ID (for ad group level negatives)"),
367
+ ] = None,
368
+ limit: Annotated[
369
+ int,
370
+ typer.Option("--limit", "-l", help="Maximum number of results"),
371
+ ] = 100,
372
+ format: Annotated[
373
+ OutputFormat,
374
+ typer.Option("--format", "-f", help="Output format"),
375
+ ] = OutputFormat.TABLE,
376
+ ) -> None:
377
+ """List negative keywords.
378
+
379
+ Examples:
380
+ asa keywords negatives list 123 # Campaign level
381
+ asa keywords negatives list 123 --ad-group 456 # Ad group level
382
+ """
383
+ client = get_client()
384
+
385
+ try:
386
+ with client:
387
+ level = "Ad Group" if ad_group_id else "Campaign"
388
+
389
+ with spinner(f"Fetching {level.lower()} negative keywords..."):
390
+ if ad_group_id:
391
+ negatives = client.campaigns(campaign_id).ad_groups(ad_group_id).negative_keywords.list(limit=limit)
392
+ else:
393
+ negatives = client.campaigns(campaign_id).negative_keywords.list(limit=limit)
394
+
395
+ if not negatives.data:
396
+ print_warning(f"No {level.lower()} negative keywords found")
397
+ return
398
+
399
+ data = [
400
+ {
401
+ "id": n.id,
402
+ "text": n.text,
403
+ "match_type": n.match_type.value,
404
+ "status": n.status.value,
405
+ }
406
+ for n in negatives
407
+ ]
408
+ output_data(
409
+ data,
410
+ NEGATIVE_KEYWORD_COLUMNS,
411
+ format,
412
+ title=f"{level} Negative Keywords ({negatives.total_results} total)",
413
+ column_labels=NEGATIVE_KEYWORD_COLUMN_LABELS,
414
+ )
415
+
416
+ except AppleSearchAdsError as e:
417
+ handle_api_error(e)
418
+ raise typer.Exit(1) from None
419
+
420
+
421
+ @negatives_app.command("add")
422
+ def add_negative(
423
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
424
+ text: Annotated[str, typer.Argument(help="Keyword text to exclude")],
425
+ ad_group_id: Annotated[
426
+ int | None,
427
+ typer.Option("--ad-group", "-a", help="Ad Group ID (for ad group level negative)"),
428
+ ] = None,
429
+ match_type: Annotated[
430
+ KeywordMatchType,
431
+ typer.Option("--match-type", "-m", help="Match type"),
432
+ ] = KeywordMatchType.EXACT,
433
+ ) -> None:
434
+ """Add a negative keyword.
435
+
436
+ Examples:
437
+ asa keywords negatives add 123 "free" # Campaign level
438
+ asa keywords negatives add 123 "cheap" --ad-group 456 # Ad group level
439
+ asa keywords negatives add 123 "competitor" --match-type BROAD
440
+ """
441
+ client = get_client()
442
+
443
+ try:
444
+ with client:
445
+ create_data = NegativeKeywordCreate(
446
+ text=text,
447
+ match_type=match_type,
448
+ )
449
+
450
+ level = "ad group" if ad_group_id else "campaign"
451
+
452
+ with spinner(f"Adding negative keyword at {level} level..."):
453
+ if ad_group_id:
454
+ negative = (
455
+ client.campaigns(campaign_id).ad_groups(ad_group_id).negative_keywords.create(create_data)
456
+ )
457
+ else:
458
+ negative = client.campaigns(campaign_id).negative_keywords.create(create_data)
459
+
460
+ print_result_panel(
461
+ "Negative Keyword Added",
462
+ {
463
+ "Level": level.title(),
464
+ "Text": negative.text,
465
+ "Match Type": negative.match_type.value,
466
+ },
467
+ )
468
+
469
+ except AppleSearchAdsError as e:
470
+ handle_api_error(e)
471
+ raise typer.Exit(1) from None
472
+
473
+
474
+ @negatives_app.command("delete")
475
+ def delete_negative(
476
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
477
+ keyword_id: Annotated[int, typer.Argument(help="Negative keyword ID to delete")],
478
+ ad_group_id: Annotated[
479
+ int | None,
480
+ typer.Option("--ad-group", "-a", help="Ad Group ID (for ad group level negative)"),
481
+ ] = None,
482
+ force: Annotated[
483
+ bool,
484
+ typer.Option("--force", "-f", help="Skip confirmation"),
485
+ ] = False,
486
+ ) -> None:
487
+ """Delete a negative keyword.
488
+
489
+ Examples:
490
+ asa keywords negatives delete 123 789 # Campaign level
491
+ asa keywords negatives delete 123 789 --ad-group 456 # Ad group level
492
+ """
493
+ client = get_client()
494
+
495
+ try:
496
+ with client:
497
+ if not force:
498
+ if not confirm_action("Are you sure you want to delete this negative keyword?"):
499
+ print_warning("Cancelled")
500
+ raise typer.Exit(0)
501
+
502
+ level = "ad group" if ad_group_id else "campaign"
503
+
504
+ with spinner(f"Deleting {level} negative keyword..."):
505
+ if ad_group_id:
506
+ client.campaigns(campaign_id).ad_groups(ad_group_id).negative_keywords.delete(keyword_id)
507
+ else:
508
+ client.campaigns(campaign_id).negative_keywords.delete(keyword_id)
509
+
510
+ print_success("Negative keyword deleted")
511
+
512
+ except AppleSearchAdsError as e:
513
+ handle_api_error(e)
514
+ raise typer.Exit(1) from None
asa_api_cli/main.py ADDED
@@ -0,0 +1,76 @@
1
+ """Main CLI application."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from asa_api_cli import ad_groups, auth, brand, campaigns, keywords, optimize, reports
9
+
10
+ app = typer.Typer(
11
+ name="asa",
12
+ help="Apple Search Ads API CLI - Manage campaigns, ad groups, keywords, and reports.",
13
+ rich_markup_mode="rich",
14
+ )
15
+
16
+ console = Console()
17
+
18
+ # Register sub-commands
19
+ app.add_typer(auth.app, name="auth", help="Authentication commands")
20
+ app.add_typer(brand.app, name="brand", help="Create brand protection campaigns")
21
+ app.add_typer(campaigns.app, name="campaigns", help="Manage campaigns")
22
+ app.add_typer(ad_groups.app, name="ad-groups", help="Manage ad groups")
23
+ app.add_typer(keywords.app, name="keywords", help="Manage keywords")
24
+ app.add_typer(reports.app, name="reports", help="Generate reports")
25
+ app.add_typer(optimize.app, name="optimize", help="Optimization tools")
26
+
27
+
28
+ def version_callback(value: bool) -> None:
29
+ """Show version and exit."""
30
+ if value:
31
+ from asa_api_client import __version__ as api_version
32
+
33
+ from asa_api_cli import __version__ as cli_version
34
+
35
+ console.print(f"asa-api-cli {cli_version} (asa-api-client {api_version})")
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback(invoke_without_command=True)
40
+ def main(
41
+ ctx: typer.Context,
42
+ version: Annotated[
43
+ bool | None,
44
+ typer.Option(
45
+ "--version",
46
+ "-v",
47
+ help="Show version and exit",
48
+ callback=version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ] = None,
52
+ ) -> None:
53
+ """Apple Search Ads API CLI.
54
+
55
+ Manage your Apple Search Ads campaigns, ad groups, keywords,
56
+ and generate performance reports from the command line.
57
+
58
+ Set up authentication using environment variables:
59
+
60
+ export ASA_CLIENT_ID="SEARCHADS.your-client-id"
61
+ export ASA_TEAM_ID="YOUR_TEAM_ID"
62
+ export ASA_KEY_ID="YOUR_KEY_ID"
63
+ export ASA_ORG_ID="123456"
64
+ export ASA_PRIVATE_KEY_PATH="/path/to/private-key.pem"
65
+
66
+ Or test your credentials:
67
+
68
+ asa auth test
69
+ """
70
+ if ctx.invoked_subcommand is None and not version:
71
+ console.print(ctx.get_help())
72
+ raise typer.Exit()
73
+
74
+
75
+ if __name__ == "__main__":
76
+ app()