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,15 @@
1
+ """Command-line interface for Apple Search Ads.
2
+
3
+ This module provides a CLI for interacting with the Apple Search Ads API.
4
+
5
+ Usage:
6
+ asa --help
7
+ asa campaigns list
8
+ asa reports campaigns --start 2024-01-01 --end 2024-01-31
9
+ """
10
+
11
+ from asa_api_cli.main import app
12
+
13
+ __all__ = ["app"]
14
+
15
+ __version__ = "0.1.0"
@@ -0,0 +1,270 @@
1
+ """Ad Group 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 AdGroupStatus, AdGroupUpdate, Money, Selector
8
+
9
+ from asa_api_cli.utils import (
10
+ OutputFormat,
11
+ confirm_action,
12
+ enum_value,
13
+ format_money,
14
+ get_client,
15
+ handle_api_error,
16
+ output_data,
17
+ print_json,
18
+ print_result_panel,
19
+ print_success,
20
+ print_warning,
21
+ spinner,
22
+ )
23
+
24
+ app = typer.Typer(help="Manage ad groups")
25
+
26
+ AD_GROUP_COLUMNS = [
27
+ "id",
28
+ "name",
29
+ "status",
30
+ "serving_status",
31
+ "default_bid",
32
+ "search_match",
33
+ ]
34
+
35
+ AD_GROUP_COLUMN_LABELS = {
36
+ "id": "ID",
37
+ "serving_status": "Serving",
38
+ "default_bid": "Default Bid",
39
+ "search_match": "Search Match",
40
+ }
41
+
42
+
43
+ def ad_group_to_dict(ad_group: object) -> dict[str, Any]:
44
+ """Convert ad group to display dictionary."""
45
+ return {
46
+ "id": ad_group.id, # type: ignore
47
+ "name": ad_group.name, # type: ignore
48
+ "status": enum_value(ad_group.status), # type: ignore
49
+ "serving_status": enum_value(ad_group.serving_status), # type: ignore
50
+ "default_bid": format_money(
51
+ ad_group.default_bid_amount.amount, # type: ignore
52
+ ad_group.default_bid_amount.currency, # type: ignore
53
+ ),
54
+ "search_match": enum_value(ad_group.automated_keywords_opt_in), # type: ignore
55
+ }
56
+
57
+
58
+ @app.command("list")
59
+ def list_ad_groups(
60
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
61
+ status: Annotated[
62
+ AdGroupStatus | None,
63
+ typer.Option("--status", "-s", help="Filter by status"),
64
+ ] = None,
65
+ limit: Annotated[
66
+ int,
67
+ typer.Option("--limit", "-l", help="Maximum number of results"),
68
+ ] = 100,
69
+ format: Annotated[
70
+ OutputFormat,
71
+ typer.Option("--format", "-f", help="Output format"),
72
+ ] = OutputFormat.TABLE,
73
+ ) -> None:
74
+ """List ad groups in a campaign.
75
+
76
+ Examples:
77
+ asa ad-groups list 123456789
78
+ asa ad-groups list 123456789 --status ENABLED
79
+ asa ad-groups list 123456789 --format json
80
+ """
81
+ client = get_client()
82
+
83
+ try:
84
+ with client:
85
+ with spinner("Fetching ad groups..."):
86
+ if status:
87
+ selector = Selector().where("status", "==", status.value).limit(limit)
88
+ ad_groups = client.campaigns(campaign_id).ad_groups.find(selector)
89
+ else:
90
+ ad_groups = client.campaigns(campaign_id).ad_groups.list(limit=limit)
91
+
92
+ if not ad_groups.data:
93
+ print_warning("No ad groups found")
94
+ return
95
+
96
+ data = [ad_group_to_dict(ag) for ag in ad_groups]
97
+ output_data(
98
+ data,
99
+ AD_GROUP_COLUMNS,
100
+ format,
101
+ title=f"Ad Groups ({ad_groups.total_results} total)",
102
+ column_labels=AD_GROUP_COLUMN_LABELS,
103
+ )
104
+
105
+ except AppleSearchAdsError as e:
106
+ handle_api_error(e)
107
+ raise typer.Exit(1) from None
108
+
109
+
110
+ @app.command("get")
111
+ def get_ad_group(
112
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
113
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
114
+ format: Annotated[
115
+ OutputFormat,
116
+ typer.Option("--format", "-f", help="Output format"),
117
+ ] = OutputFormat.JSON,
118
+ ) -> None:
119
+ """Get details for a specific ad group.
120
+
121
+ Examples:
122
+ asa ad-groups get 123456789 987654321
123
+ asa ad-groups get 123456789 987654321 --format table
124
+ """
125
+ client = get_client()
126
+
127
+ try:
128
+ with client:
129
+ with spinner("Fetching ad group..."):
130
+ ad_group = client.campaigns(campaign_id).ad_groups.get(ad_group_id)
131
+
132
+ if format == OutputFormat.JSON:
133
+ print_json(ad_group, title=f"Ad Group {ad_group_id}")
134
+ else:
135
+ data = [ad_group_to_dict(ad_group)]
136
+ output_data(data, AD_GROUP_COLUMNS, format, column_labels=AD_GROUP_COLUMN_LABELS)
137
+
138
+ except AppleSearchAdsError as e:
139
+ handle_api_error(e)
140
+ raise typer.Exit(1) from None
141
+
142
+
143
+ @app.command("pause")
144
+ def pause_ad_group(
145
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
146
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID to pause")],
147
+ ) -> None:
148
+ """Pause an ad group.
149
+
150
+ Examples:
151
+ asa ad-groups pause 123456789 987654321
152
+ """
153
+ client = get_client()
154
+
155
+ try:
156
+ with client:
157
+ with spinner("Pausing ad group..."):
158
+ ad_group = client.campaigns(campaign_id).ad_groups.update(
159
+ ad_group_id,
160
+ data=AdGroupUpdate(status=AdGroupStatus.PAUSED),
161
+ )
162
+ print_success(f"Ad group '{ad_group.name}' paused")
163
+
164
+ except AppleSearchAdsError as e:
165
+ handle_api_error(e)
166
+ raise typer.Exit(1) from None
167
+
168
+
169
+ @app.command("enable")
170
+ def enable_ad_group(
171
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
172
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID to enable")],
173
+ ) -> None:
174
+ """Enable a paused ad group.
175
+
176
+ Examples:
177
+ asa ad-groups enable 123456789 987654321
178
+ """
179
+ client = get_client()
180
+
181
+ try:
182
+ with client:
183
+ with spinner("Enabling ad group..."):
184
+ ad_group = client.campaigns(campaign_id).ad_groups.update(
185
+ ad_group_id,
186
+ data=AdGroupUpdate(status=AdGroupStatus.ENABLED),
187
+ )
188
+ print_success(f"Ad group '{ad_group.name}' enabled")
189
+
190
+ except AppleSearchAdsError as e:
191
+ handle_api_error(e)
192
+ raise typer.Exit(1) from None
193
+
194
+
195
+ @app.command("set-bid")
196
+ def set_default_bid(
197
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
198
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID")],
199
+ bid: Annotated[float, typer.Argument(help="New default bid amount")],
200
+ currency: Annotated[
201
+ str,
202
+ typer.Option("--currency", "-c", help="Currency code"),
203
+ ] = "USD",
204
+ ) -> None:
205
+ """Set the default bid for an ad group.
206
+
207
+ Examples:
208
+ asa ad-groups set-bid 123456789 987654321 2.50
209
+ asa ad-groups set-bid 123456789 987654321 2.50 --currency EUR
210
+ """
211
+ client = get_client()
212
+
213
+ try:
214
+ with client:
215
+ with spinner("Updating default bid..."):
216
+ ad_group = client.campaigns(campaign_id).ad_groups.update(
217
+ ad_group_id,
218
+ data=AdGroupUpdate(default_bid_amount=Money(amount=str(bid), currency=currency)),
219
+ )
220
+
221
+ print_result_panel(
222
+ "Default Bid Updated",
223
+ {
224
+ "Ad Group": ad_group.name,
225
+ "Default Bid": f"{ad_group.default_bid_amount.amount} {ad_group.default_bid_amount.currency}",
226
+ },
227
+ )
228
+
229
+ except AppleSearchAdsError as e:
230
+ handle_api_error(e)
231
+ raise typer.Exit(1) from None
232
+
233
+
234
+ @app.command("delete")
235
+ def delete_ad_group(
236
+ campaign_id: Annotated[int, typer.Argument(help="Campaign ID")],
237
+ ad_group_id: Annotated[int, typer.Argument(help="Ad Group ID to delete")],
238
+ force: Annotated[
239
+ bool,
240
+ typer.Option("--force", "-f", help="Skip confirmation"),
241
+ ] = False,
242
+ ) -> None:
243
+ """Delete an ad group.
244
+
245
+ WARNING: This action cannot be undone.
246
+
247
+ Examples:
248
+ asa ad-groups delete 123456789 987654321
249
+ asa ad-groups delete 123456789 987654321 --force
250
+ """
251
+ client = get_client()
252
+
253
+ try:
254
+ with client:
255
+ with spinner("Fetching ad group..."):
256
+ ad_group = client.campaigns(campaign_id).ad_groups.get(ad_group_id)
257
+
258
+ if not force:
259
+ if not confirm_action(f"Are you sure you want to delete ad group '{ad_group.name}'?"):
260
+ print_warning("Cancelled")
261
+ raise typer.Exit(0)
262
+
263
+ with spinner("Deleting ad group..."):
264
+ client.campaigns(campaign_id).ad_groups.delete(ad_group_id)
265
+
266
+ print_success(f"Ad group '{ad_group.name}' deleted")
267
+
268
+ except AppleSearchAdsError as e:
269
+ handle_api_error(e)
270
+ raise typer.Exit(1) from None
asa_api_cli/auth.py ADDED
@@ -0,0 +1,186 @@
1
+ """Authentication CLI commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from asa_api_client import AppleSearchAdsClient, Settings
8
+ from asa_api_client.exceptions import AppleSearchAdsError, ConfigurationError
9
+ from pydantic import ValidationError
10
+ from rich.table import Table
11
+
12
+ from asa_api_cli.utils import (
13
+ console,
14
+ print_error,
15
+ print_info,
16
+ print_result_panel,
17
+ spinner,
18
+ )
19
+
20
+ app = typer.Typer(help="Authentication commands")
21
+
22
+
23
+ @app.command("test")
24
+ def test_auth(
25
+ env_file: Annotated[
26
+ Path | None,
27
+ typer.Option("--env-file", "-e", help="Path to .env file"),
28
+ ] = Path(".env"),
29
+ ) -> None:
30
+ """Test authentication credentials.
31
+
32
+ Loads configuration from environment variables and .env file,
33
+ then attempts to authenticate with the Apple Search Ads API.
34
+
35
+ Examples:
36
+ asa auth test
37
+ asa auth test --env-file .env.production
38
+ """
39
+ print_info("Testing Apple Search Ads API credentials...")
40
+ console.print()
41
+
42
+ # Try to load settings
43
+ try:
44
+ if env_file and env_file.exists():
45
+ settings = Settings(_env_file=env_file) # type: ignore[call-arg]
46
+ print_info(f"Loaded configuration from {env_file}")
47
+ else:
48
+ settings = Settings(_env_file=None) # type: ignore[call-arg]
49
+ if env_file:
50
+ print_info("No .env file found, using environment variables only")
51
+ except ValidationError as e:
52
+ # Show what's missing
53
+ table = Table(
54
+ title="Configuration Status",
55
+ show_header=True,
56
+ header_style="header",
57
+ border_style="muted",
58
+ )
59
+ table.add_column("Setting", style="label")
60
+ table.add_column("Status")
61
+
62
+ errors_by_field = {err["loc"][0]: err["msg"] for err in e.errors()}
63
+
64
+ for field in ["client_id", "team_id", "key_id", "org_id", "private_key", "private_key_path"]:
65
+ if field in errors_by_field:
66
+ table.add_row(f"ASA_{field.upper()}", f"[error]{errors_by_field[field]}[/error]")
67
+ else:
68
+ table.add_row(f"ASA_{field.upper()}", "[success]OK[/success]")
69
+
70
+ console.print(table)
71
+ console.print()
72
+ print_error("Configuration Error", "Missing or invalid settings")
73
+ raise typer.Exit(1) from None
74
+
75
+ # Display loaded configuration
76
+ table = Table(
77
+ title="Configuration",
78
+ show_header=True,
79
+ header_style="header",
80
+ border_style="muted",
81
+ )
82
+ table.add_column("Setting", style="label")
83
+ table.add_column("Value")
84
+
85
+ # Mask client_id
86
+ client_id_display = settings.client_id[:20] + "..." if len(settings.client_id) > 20 else settings.client_id
87
+ table.add_row("ASA_CLIENT_ID", f"[success]{client_id_display}[/success]")
88
+ table.add_row("ASA_TEAM_ID", f"[success]{settings.team_id}[/success]")
89
+ table.add_row("ASA_KEY_ID", f"[success]{settings.key_id}[/success]")
90
+ table.add_row("ASA_ORG_ID", f"[success]{settings.org_id}[/success]")
91
+
92
+ if settings.private_key_path:
93
+ table.add_row("ASA_PRIVATE_KEY_PATH", f"[success]{settings.private_key_path}[/success]")
94
+ if settings.private_key:
95
+ table.add_row("ASA_PRIVATE_KEY", "[success]<set>[/success]")
96
+
97
+ console.print(table)
98
+ console.print()
99
+
100
+ # Try to authenticate
101
+ print_info("Attempting to authenticate...")
102
+
103
+ try:
104
+ client = AppleSearchAdsClient.from_env(env_file=env_file)
105
+ except ConfigurationError as e:
106
+ print_error("Configuration Error", e.message)
107
+ raise typer.Exit(1) from None
108
+
109
+ try:
110
+ # Try to list campaigns to verify authentication works
111
+ with client:
112
+ with spinner("Authenticating with Apple Search Ads API..."):
113
+ campaigns = client.campaigns.list(limit=1)
114
+
115
+ print_result_panel(
116
+ "Authentication Successful",
117
+ {
118
+ "Organization ID": str(client.org_id),
119
+ "Total Campaigns": str(campaigns.total_results),
120
+ },
121
+ )
122
+
123
+ except AppleSearchAdsError as e:
124
+ print_error("API Error", e.message, f"Status code: {e.status_code}" if e.status_code else None)
125
+ raise typer.Exit(1) from None
126
+
127
+
128
+ @app.command("show")
129
+ def show_config(
130
+ env_file: Annotated[
131
+ Path | None,
132
+ typer.Option("--env-file", "-e", help="Path to .env file"),
133
+ ] = Path(".env"),
134
+ ) -> None:
135
+ """Show current authentication configuration.
136
+
137
+ Displays configuration loaded from environment variables and .env file.
138
+
139
+ Examples:
140
+ asa auth show
141
+ asa auth show --env-file .env.production
142
+ """
143
+ # Try to load settings
144
+ try:
145
+ if env_file and env_file.exists():
146
+ settings = Settings(_env_file=env_file) # type: ignore[call-arg]
147
+ source = f"from {env_file}"
148
+ else:
149
+ settings = Settings(_env_file=None) # type: ignore[call-arg]
150
+ source = "from environment variables"
151
+
152
+ table = Table(
153
+ title=f"Current Configuration ({source})",
154
+ show_header=True,
155
+ header_style="header",
156
+ border_style="muted",
157
+ )
158
+ table.add_column("Setting", style="label")
159
+ table.add_column("Value")
160
+
161
+ # Mask client_id partially
162
+ client_id_display = settings.client_id[:20] + "..." if len(settings.client_id) > 20 else settings.client_id
163
+ table.add_row("ASA_CLIENT_ID", client_id_display)
164
+ table.add_row("ASA_TEAM_ID", settings.team_id)
165
+ table.add_row("ASA_KEY_ID", settings.key_id)
166
+ table.add_row("ASA_ORG_ID", str(settings.org_id))
167
+
168
+ if settings.private_key_path:
169
+ table.add_row("ASA_PRIVATE_KEY_PATH", str(settings.private_key_path))
170
+ else:
171
+ table.add_row("ASA_PRIVATE_KEY_PATH", "[muted]<not set>[/muted]")
172
+
173
+ if settings.private_key:
174
+ table.add_row("ASA_PRIVATE_KEY", "[success]<set>[/success]")
175
+ else:
176
+ table.add_row("ASA_PRIVATE_KEY", "[muted]<not set>[/muted]")
177
+
178
+ console.print(table)
179
+
180
+ except ValidationError as e:
181
+ print_error("Configuration Error", "Could not load settings")
182
+ for err in e.errors():
183
+ field = err["loc"][0]
184
+ msg = err["msg"]
185
+ console.print(f" [error]ASA_{str(field).upper()}:[/error] {msg}")
186
+ raise typer.Exit(1) from None