asa-api-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (35) hide show
  1. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/workflows/ci.yml +1 -1
  2. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/PKG-INFO +2 -2
  3. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/__init__.py +6 -1
  4. asa_api_cli-0.3.0/asa_api_cli/ads.py +345 -0
  5. asa_api_cli-0.3.0/asa_api_cli/apps.py +81 -0
  6. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/auth.py +57 -1
  7. asa_api_cli-0.3.0/asa_api_cli/budget_orders.py +114 -0
  8. asa_api_cli-0.3.0/asa_api_cli/countries.py +84 -0
  9. asa_api_cli-0.3.0/asa_api_cli/geo.py +92 -0
  10. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/main.py +18 -1
  11. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/optimize.py +473 -3
  12. asa_api_cli-0.3.0/asa_api_cli/product_pages.py +143 -0
  13. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/reports.py +6 -6
  14. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/pyproject.toml +2 -2
  15. asa_api_cli-0.3.0/tests/test_cli.py +139 -0
  16. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/uv.lock +444 -475
  17. asa_api_cli-0.2.0/tests/test_cli.py +0 -39
  18. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/dependabot.yml +0 -0
  22. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  23. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.gitignore +0 -0
  24. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/.pre-commit-config.yaml +0 -0
  25. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/LICENSE +0 -0
  26. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/README.md +0 -0
  27. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/ad_groups.py +0 -0
  28. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/brand.py +0 -0
  29. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/campaigns.py +0 -0
  30. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/impression_share.py +0 -0
  31. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/keywords.py +0 -0
  32. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/languages.py +0 -0
  33. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/translate.py +0 -0
  34. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/asa_api_cli/utils.py +0 -0
  35. {asa_api_cli-0.2.0 → asa_api_cli-0.3.0}/tests/__init__.py +0 -0
@@ -40,7 +40,7 @@ jobs:
40
40
  run: uv run pytest --cov=asa_api_cli --cov-report=xml
41
41
 
42
42
  - name: Upload coverage to Codecov
43
- uses: codecov/codecov-action@v5
43
+ uses: codecov/codecov-action@v7
44
44
  with:
45
45
  file: ./coverage.xml
46
46
  fail_ci_if_error: false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asa-api-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Command-line interface for Apple Search Ads campaign management
5
5
  Project-URL: Homepage, https://github.com/SamPetherbridge/asa-api-cli
6
6
  Project-URL: Repository, https://github.com/SamPetherbridge/asa-api-cli
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.13
21
- Requires-Dist: asa-api-client>=0.1.5
21
+ Requires-Dist: asa-api-client>=0.2.1
22
22
  Requires-Dist: pydantic-ai>=0.1.0
23
23
  Requires-Dist: pydantic-settings>=2.0.0
24
24
  Requires-Dist: rich>=13.9.0
@@ -8,8 +8,13 @@ Usage:
8
8
  asa reports campaigns --start 2024-01-01 --end 2024-01-31
9
9
  """
10
10
 
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
11
13
  from asa_api_cli.main import app
12
14
 
13
15
  __all__ = ["app"]
14
16
 
15
- __version__ = "0.2.0"
17
+ try:
18
+ __version__ = version("asa-api-cli")
19
+ except PackageNotFoundError: # pragma: no cover - only when running from a non-installed checkout
20
+ __version__ = "0.0.0"
@@ -0,0 +1,345 @@
1
+ """Ad (creative) 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 AdCreate, AdStatus, AdUpdate, CreativeType
8
+
9
+ from asa_api_cli.utils import (
10
+ EXIT_ERROR,
11
+ EXIT_USAGE,
12
+ OutputFormat,
13
+ confirm_action,
14
+ enum_value,
15
+ get_client,
16
+ handle_api_error,
17
+ output_data,
18
+ print_error,
19
+ print_info,
20
+ print_result_panel,
21
+ print_success,
22
+ print_warning,
23
+ spinner,
24
+ )
25
+
26
+ app = typer.Typer(help="View and manage ads (creatives) within ad groups")
27
+
28
+ AD_COLUMNS = ["id", "name", "creative_type", "status", "serving_status", "creative_id"]
29
+
30
+
31
+ def _validate_create(creative_type: CreativeType, product_page_id: str | None) -> None:
32
+ """Validate the creative-type / product-page combination before any API call.
33
+
34
+ Raises typer.Exit(EXIT_USAGE) with a friendly message on an invalid combination.
35
+ """
36
+ if creative_type == CreativeType.CREATIVE_SET:
37
+ print_error(
38
+ "Unsupported creative type",
39
+ "CREATIVE_SET ads require selecting creative set assets, which this command "
40
+ "does not support yet. Use CUSTOM_PRODUCT_PAGE or DEFAULT_PRODUCT_PAGE.",
41
+ )
42
+ raise typer.Exit(EXIT_USAGE)
43
+ if creative_type == CreativeType.CUSTOM_PRODUCT_PAGE and not product_page_id:
44
+ print_error(
45
+ "Missing product page",
46
+ "CUSTOM_PRODUCT_PAGE ads require --product-page <id>. Find ids with 'asa product-pages list'.",
47
+ )
48
+ raise typer.Exit(EXIT_USAGE)
49
+ if creative_type == CreativeType.DEFAULT_PRODUCT_PAGE and product_page_id:
50
+ print_error(
51
+ "Unexpected product page",
52
+ "DEFAULT_PRODUCT_PAGE ads must not specify --product-page.",
53
+ )
54
+ raise typer.Exit(EXIT_USAGE)
55
+
56
+
57
+ @app.command("list")
58
+ def list_ads(
59
+ campaign_id: Annotated[
60
+ int,
61
+ typer.Option("--campaign", "-c", help="Campaign ID"),
62
+ ],
63
+ ad_group_id: Annotated[
64
+ int,
65
+ typer.Option("--ad-group", "-a", help="Ad group ID"),
66
+ ],
67
+ limit: Annotated[
68
+ int,
69
+ typer.Option("--limit", "-l", help="Maximum number of results"),
70
+ ] = 100,
71
+ format: Annotated[
72
+ OutputFormat,
73
+ typer.Option("--format", "-f", help="Output format"),
74
+ ] = OutputFormat.TABLE,
75
+ ) -> None:
76
+ """List ads in an ad group.
77
+
78
+ Examples:
79
+ asa ads list --campaign 123 --ad-group 456
80
+ asa ads list -c 123 -a 456 --format json
81
+ """
82
+ client = get_client()
83
+
84
+ try:
85
+ with client:
86
+ with spinner("Fetching ads..."):
87
+ result = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.list(limit=limit)
88
+
89
+ if not result.data:
90
+ print_warning("No ads found")
91
+ return
92
+
93
+ rows: list[dict[str, Any]] = [
94
+ {
95
+ "id": ad.id,
96
+ "name": ad.name,
97
+ "creative_type": enum_value(ad.creative_type),
98
+ "status": enum_value(ad.status),
99
+ "serving_status": enum_value(ad.serving_status),
100
+ "creative_id": ad.creative_id or "-",
101
+ }
102
+ for ad in result.data
103
+ ]
104
+
105
+ output_data(rows, AD_COLUMNS, format, title=f"Ads in ad group {ad_group_id}")
106
+ except AppleSearchAdsError as e:
107
+ handle_api_error(e)
108
+ raise typer.Exit(EXIT_ERROR) from None
109
+
110
+
111
+ @app.command("get")
112
+ def get_ad(
113
+ ad_id: Annotated[
114
+ int,
115
+ typer.Argument(help="Ad ID"),
116
+ ],
117
+ campaign_id: Annotated[
118
+ int,
119
+ typer.Option("--campaign", "-c", help="Campaign ID"),
120
+ ],
121
+ ad_group_id: Annotated[
122
+ int,
123
+ typer.Option("--ad-group", "-a", help="Ad group ID"),
124
+ ],
125
+ ) -> None:
126
+ """Show details for a single ad.
127
+
128
+ Example:
129
+ asa ads get 789 --campaign 123 --ad-group 456
130
+ """
131
+ client = get_client()
132
+
133
+ try:
134
+ with client:
135
+ with spinner("Fetching ad..."):
136
+ ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.get(ad_id)
137
+
138
+ print_result_panel(
139
+ f"Ad {ad.id}",
140
+ {
141
+ "Name": ad.name,
142
+ "Creative type": enum_value(ad.creative_type),
143
+ "Status": enum_value(ad.status),
144
+ "Serving status": enum_value(ad.serving_status),
145
+ "Creative ID": str(ad.creative_id) if ad.creative_id else "-",
146
+ "Campaign ID": str(ad.campaign_id),
147
+ "Ad group ID": str(ad.ad_group_id),
148
+ },
149
+ )
150
+ except AppleSearchAdsError as e:
151
+ handle_api_error(e)
152
+ raise typer.Exit(EXIT_ERROR) from None
153
+
154
+
155
+ @app.command("create")
156
+ def create_ad(
157
+ name: Annotated[
158
+ str,
159
+ typer.Option("--name", "-n", help="Ad name"),
160
+ ],
161
+ campaign_id: Annotated[
162
+ int,
163
+ typer.Option("--campaign", "-c", help="Campaign ID"),
164
+ ],
165
+ ad_group_id: Annotated[
166
+ int,
167
+ typer.Option("--ad-group", "-a", help="Ad group ID"),
168
+ ],
169
+ creative_type: Annotated[
170
+ CreativeType,
171
+ typer.Option("--creative-type", "-t", help="Creative type"),
172
+ ] = CreativeType.CUSTOM_PRODUCT_PAGE,
173
+ product_page_id: Annotated[
174
+ str | None,
175
+ typer.Option("--product-page", "-p", help="Custom product page ID (required for CUSTOM_PRODUCT_PAGE)"),
176
+ ] = None,
177
+ status: Annotated[
178
+ AdStatus,
179
+ typer.Option("--status", "-s", help="Initial status"),
180
+ ] = AdStatus.ENABLED,
181
+ dry_run: Annotated[
182
+ bool,
183
+ typer.Option("--dry-run", help="Preview the ad payload without creating it"),
184
+ ] = False,
185
+ yes: Annotated[
186
+ bool,
187
+ typer.Option("--yes", "-y", help="Skip the confirmation prompt"),
188
+ ] = False,
189
+ ) -> None:
190
+ """Create an ad in an ad group.
191
+
192
+ CUSTOM_PRODUCT_PAGE ads need a --product-page id (see 'asa product-pages list').
193
+ DEFAULT_PRODUCT_PAGE ads use the app's default page and take no product page.
194
+
195
+ Examples:
196
+ asa ads create -c 123 -a 456 -n "Holiday CPP" -p <product-page-id>
197
+ asa ads create -c 123 -a 456 -n "Default ad" -t DEFAULT_PRODUCT_PAGE
198
+ asa ads create -c 123 -a 456 -n "Preview" -p <id> --dry-run
199
+ """
200
+ _validate_create(creative_type, product_page_id)
201
+
202
+ payload = AdCreate(
203
+ name=name,
204
+ creative_type=creative_type,
205
+ status=status,
206
+ product_page_id=product_page_id,
207
+ )
208
+
209
+ preview = {
210
+ "Name": name,
211
+ "Creative type": enum_value(creative_type),
212
+ "Status": enum_value(status),
213
+ "Product page": product_page_id or "-",
214
+ "Campaign ID": str(campaign_id),
215
+ "Ad group ID": str(ad_group_id),
216
+ }
217
+
218
+ if dry_run:
219
+ print_info("Dry run - no ad created")
220
+ print_result_panel("Ad payload", preview)
221
+ return
222
+
223
+ if not yes and not confirm_action(f"Create ad '{name}' in ad group {ad_group_id}?", default=True):
224
+ print_info("Cancelled")
225
+ return
226
+
227
+ client = get_client()
228
+
229
+ try:
230
+ with client:
231
+ with spinner("Creating ad..."):
232
+ ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.create(payload)
233
+
234
+ print_success(f"Created ad {ad.id}: {ad.name}")
235
+ print_result_panel(
236
+ f"Ad {ad.id}",
237
+ {
238
+ "Name": ad.name,
239
+ "Creative type": enum_value(ad.creative_type),
240
+ "Status": enum_value(ad.status),
241
+ "Serving status": enum_value(ad.serving_status),
242
+ },
243
+ )
244
+ except AppleSearchAdsError as e:
245
+ handle_api_error(e)
246
+ raise typer.Exit(EXIT_ERROR) from None
247
+
248
+
249
+ @app.command("update")
250
+ def update_ad(
251
+ ad_id: Annotated[
252
+ int,
253
+ typer.Argument(help="Ad ID"),
254
+ ],
255
+ campaign_id: Annotated[
256
+ int,
257
+ typer.Option("--campaign", "-c", help="Campaign ID"),
258
+ ],
259
+ ad_group_id: Annotated[
260
+ int,
261
+ typer.Option("--ad-group", "-a", help="Ad group ID"),
262
+ ],
263
+ name: Annotated[
264
+ str | None,
265
+ typer.Option("--name", "-n", help="New ad name"),
266
+ ] = None,
267
+ status: Annotated[
268
+ AdStatus | None,
269
+ typer.Option("--status", "-s", help="New status (ENABLED or PAUSED)"),
270
+ ] = None,
271
+ ) -> None:
272
+ """Update an ad's name and/or status.
273
+
274
+ At least one of --name or --status must be provided.
275
+
276
+ Examples:
277
+ asa ads update 789 -c 123 -a 456 --status PAUSED
278
+ asa ads update 789 -c 123 -a 456 --name "Renamed ad"
279
+ """
280
+ if name is None and status is None:
281
+ print_error("Nothing to update", "Provide at least one of --name or --status.")
282
+ raise typer.Exit(EXIT_USAGE)
283
+
284
+ payload = AdUpdate(name=name, status=status)
285
+
286
+ client = get_client()
287
+
288
+ try:
289
+ with client:
290
+ with spinner("Updating ad..."):
291
+ ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.update(ad_id, payload)
292
+
293
+ print_success(f"Updated ad {ad.id}")
294
+ print_result_panel(
295
+ f"Ad {ad.id}",
296
+ {
297
+ "Name": ad.name,
298
+ "Status": enum_value(ad.status),
299
+ "Serving status": enum_value(ad.serving_status),
300
+ },
301
+ )
302
+ except AppleSearchAdsError as e:
303
+ handle_api_error(e)
304
+ raise typer.Exit(EXIT_ERROR) from None
305
+
306
+
307
+ @app.command("delete")
308
+ def delete_ad(
309
+ ad_id: Annotated[
310
+ int,
311
+ typer.Argument(help="Ad ID"),
312
+ ],
313
+ campaign_id: Annotated[
314
+ int,
315
+ typer.Option("--campaign", "-c", help="Campaign ID"),
316
+ ],
317
+ ad_group_id: Annotated[
318
+ int,
319
+ typer.Option("--ad-group", "-a", help="Ad group ID"),
320
+ ],
321
+ yes: Annotated[
322
+ bool,
323
+ typer.Option("--yes", "-y", help="Skip the confirmation prompt"),
324
+ ] = False,
325
+ ) -> None:
326
+ """Delete an ad.
327
+
328
+ Example:
329
+ asa ads delete 789 -c 123 -a 456
330
+ """
331
+ if not yes and not confirm_action(f"Delete ad {ad_id}? This cannot be undone.", default=False):
332
+ print_info("Cancelled")
333
+ return
334
+
335
+ client = get_client()
336
+
337
+ try:
338
+ with client:
339
+ with spinner("Deleting ad..."):
340
+ client.campaigns(campaign_id).ad_groups(ad_group_id).ads.delete(ad_id)
341
+
342
+ print_success(f"Deleted ad {ad_id}")
343
+ except AppleSearchAdsError as e:
344
+ handle_api_error(e)
345
+ raise typer.Exit(EXIT_ERROR) from None
@@ -0,0 +1,81 @@
1
+ """App lookup CLI commands."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+ from asa_api_client.exceptions import AppleSearchAdsError
7
+
8
+ from asa_api_cli.utils import (
9
+ EXIT_ERROR,
10
+ OutputFormat,
11
+ get_client,
12
+ handle_api_error,
13
+ output_data,
14
+ print_warning,
15
+ spinner,
16
+ )
17
+
18
+ app = typer.Typer(help="Search the App Store for advertisable apps")
19
+
20
+ APP_COLUMNS = ["adam_id", "app_name", "developer_name", "countries"]
21
+
22
+
23
+ @app.command("search")
24
+ def search_apps(
25
+ query: Annotated[
26
+ str,
27
+ typer.Argument(help="Search term (app name or keyword)"),
28
+ ],
29
+ own: Annotated[
30
+ bool,
31
+ typer.Option("--own", help="Only return apps owned by your org"),
32
+ ] = False,
33
+ limit: Annotated[
34
+ int,
35
+ typer.Option("--limit", "-l", help="Maximum number of results"),
36
+ ] = 50,
37
+ format: Annotated[
38
+ OutputFormat,
39
+ typer.Option("--format", "-f", help="Output format"),
40
+ ] = OutputFormat.TABLE,
41
+ ) -> None:
42
+ """Search for iOS apps eligible for advertising.
43
+
44
+ Returns each app's adamId — the identifier you need to create campaigns.
45
+
46
+ Examples:
47
+ asa apps search "weather"
48
+ asa apps search "my app" --own
49
+ asa apps search photo --limit 10 --format json
50
+ """
51
+ client = get_client()
52
+
53
+ try:
54
+ with client:
55
+ with spinner("Searching App Store..."):
56
+ results = client.apps.search(query=query, return_own_apps=own, limit=limit)
57
+
58
+ if not results:
59
+ print_warning("No apps found")
60
+ return
61
+
62
+ rows: list[dict[str, Any]] = [
63
+ {
64
+ "adam_id": app_info.adam_id,
65
+ "app_name": app_info.app_name,
66
+ "developer_name": app_info.developer_name or "-",
67
+ "countries": ", ".join(app_info.country_or_region_codes or []) or "-",
68
+ }
69
+ for app_info in results
70
+ ]
71
+
72
+ output_data(
73
+ rows,
74
+ APP_COLUMNS,
75
+ format,
76
+ title=f"App search: {query}",
77
+ column_labels={"adam_id": "adam ID"},
78
+ )
79
+ except AppleSearchAdsError as e:
80
+ handle_api_error(e)
81
+ raise typer.Exit(EXIT_ERROR) from None
@@ -1,7 +1,7 @@
1
1
  """Authentication CLI commands."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Annotated
4
+ from typing import Annotated, Any
5
5
 
6
6
  import typer
7
7
  from asa_api_client import AppleSearchAdsClient, Settings
@@ -12,8 +12,12 @@ from rich.table import Table
12
12
  from asa_api_cli.utils import (
13
13
  EXIT_ERROR,
14
14
  EXIT_USAGE,
15
+ OutputFormat,
15
16
  console,
16
17
  error_console,
18
+ get_client,
19
+ handle_api_error,
20
+ output_data,
17
21
  print_error,
18
22
  print_info,
19
23
  print_result_panel,
@@ -200,3 +204,55 @@ def show_config(
200
204
  msg = err["msg"]
201
205
  error_console.print(f" [error]ASA_{str(field).upper()}:[/error] {msg}")
202
206
  raise typer.Exit(EXIT_USAGE) from None
207
+
208
+
209
+ ORG_COLUMNS = ["org_id", "org_name", "currency", "role_names", "time_zone"]
210
+
211
+
212
+ @app.command("orgs")
213
+ def list_orgs(
214
+ format: Annotated[
215
+ OutputFormat,
216
+ typer.Option("--format", "-f", help="Output format"),
217
+ ] = OutputFormat.TABLE,
218
+ ) -> None:
219
+ """List organizations your credentials can access.
220
+
221
+ Shows each org's ID (use as ASA_ORG_ID), name, currency, and your roles.
222
+
223
+ Examples:
224
+ asa auth orgs
225
+ asa auth orgs --format json
226
+ """
227
+ client = get_client()
228
+
229
+ try:
230
+ with client:
231
+ with spinner("Fetching accessible organizations..."):
232
+ acls = client.acls.list()
233
+
234
+ if not acls:
235
+ print_warning("No organizations found for these credentials")
236
+ return
237
+
238
+ rows: list[dict[str, Any]] = [
239
+ {
240
+ "org_id": acl.org_id,
241
+ "org_name": acl.org_name,
242
+ "currency": acl.currency or "-",
243
+ "role_names": ", ".join(acl.role_names) or "-",
244
+ "time_zone": acl.time_zone or "-",
245
+ }
246
+ for acl in acls
247
+ ]
248
+
249
+ output_data(
250
+ rows,
251
+ ORG_COLUMNS,
252
+ format,
253
+ title="Accessible organizations",
254
+ column_labels={"org_id": "Org ID", "time_zone": "Time Zone"},
255
+ )
256
+ except AppleSearchAdsError as e:
257
+ handle_api_error(e)
258
+ raise typer.Exit(EXIT_ERROR) from None
@@ -0,0 +1,114 @@
1
+ """Budget order CLI commands."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+ from asa_api_client.exceptions import AppleSearchAdsError
7
+
8
+ from asa_api_cli.utils import (
9
+ EXIT_ERROR,
10
+ OutputFormat,
11
+ enum_value,
12
+ format_money,
13
+ get_client,
14
+ handle_api_error,
15
+ output_data,
16
+ print_result_panel,
17
+ print_warning,
18
+ spinner,
19
+ )
20
+
21
+ app = typer.Typer(help="View budget orders")
22
+
23
+ BUDGET_ORDER_COLUMNS = ["id", "name", "status", "budget", "order_number", "start_date", "end_date"]
24
+
25
+
26
+ def _date_str(value: object) -> str:
27
+ """Render a datetime/date-ish value as an ISO date, or '-'."""
28
+ if value is None:
29
+ return "-"
30
+ return str(value).split("T")[0].split(" ")[0]
31
+
32
+
33
+ @app.command("list")
34
+ def list_budget_orders(
35
+ limit: Annotated[
36
+ int,
37
+ typer.Option("--limit", "-l", help="Maximum number of results"),
38
+ ] = 100,
39
+ format: Annotated[
40
+ OutputFormat,
41
+ typer.Option("--format", "-f", help="Output format"),
42
+ ] = OutputFormat.TABLE,
43
+ ) -> None:
44
+ """List budget orders.
45
+
46
+ Examples:
47
+ asa budget-orders list
48
+ asa budget-orders list --format json
49
+ """
50
+ client = get_client()
51
+
52
+ try:
53
+ with client:
54
+ with spinner("Fetching budget orders..."):
55
+ result = client.budget_orders.list(limit=limit)
56
+
57
+ if not result.data:
58
+ print_warning("No budget orders found")
59
+ return
60
+
61
+ rows: list[dict[str, Any]] = [
62
+ {
63
+ "id": bo.id,
64
+ "name": bo.name or "-",
65
+ "status": enum_value(bo.status) if bo.status else "-",
66
+ "budget": format_money(bo.budget.amount, bo.budget.currency) if bo.budget else "-",
67
+ "order_number": bo.order_number or "-",
68
+ "start_date": _date_str(bo.start_date),
69
+ "end_date": _date_str(bo.end_date),
70
+ }
71
+ for bo in result.data
72
+ ]
73
+
74
+ output_data(rows, BUDGET_ORDER_COLUMNS, format, title="Budget orders")
75
+ except AppleSearchAdsError as e:
76
+ handle_api_error(e)
77
+ raise typer.Exit(EXIT_ERROR) from None
78
+
79
+
80
+ @app.command("get")
81
+ def get_budget_order(
82
+ budget_order_id: Annotated[
83
+ int,
84
+ typer.Argument(help="Budget order ID"),
85
+ ],
86
+ ) -> None:
87
+ """Show details for a single budget order.
88
+
89
+ Example:
90
+ asa budget-orders get 123456
91
+ """
92
+ client = get_client()
93
+
94
+ try:
95
+ with client:
96
+ with spinner("Fetching budget order..."):
97
+ bo = client.budget_orders.get(budget_order_id)
98
+
99
+ print_result_panel(
100
+ f"Budget order {bo.id}",
101
+ {
102
+ "Name": bo.name or "-",
103
+ "Status": enum_value(bo.status) if bo.status else "-",
104
+ "Budget": format_money(bo.budget.amount, bo.budget.currency) if bo.budget else "-",
105
+ "Order number": bo.order_number or "-",
106
+ "Start date": _date_str(bo.start_date),
107
+ "End date": _date_str(bo.end_date),
108
+ "Client name": bo.client_name or "-",
109
+ "Billing email": bo.billing_email or "-",
110
+ },
111
+ )
112
+ except AppleSearchAdsError as e:
113
+ handle_api_error(e)
114
+ raise typer.Exit(EXIT_ERROR) from None