asa-api-cli 0.3.0__tar.gz → 0.4.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.
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/dependabot.yml +1 -1
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/workflows/ci.yml +1 -1
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/workflows/publish.yml +1 -1
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/PKG-INFO +21 -3
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/README.md +16 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/ad_groups.py +2 -4
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/auth.py +20 -4
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/impression_share.py +12 -3
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/keywords.py +4 -4
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/main.py +39 -1
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/optimize.py +27 -21
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/product_pages.py +20 -8
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/utils.py +11 -3
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/pyproject.toml +6 -2
- asa_api_cli-0.4.0/tests/test_cli.py +273 -0
- asa_api_cli-0.4.0/uv.lock +2514 -0
- asa_api_cli-0.3.0/tests/test_cli.py +0 -139
- asa_api_cli-0.3.0/uv.lock +0 -3098
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.gitignore +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/.pre-commit-config.yaml +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/LICENSE +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/__init__.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/ads.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/apps.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/brand.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/budget_orders.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/campaigns.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/countries.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/geo.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/languages.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/reports.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/asa_api_cli/translate.py +0 -0
- {asa_api_cli-0.3.0 → asa_api_cli-0.4.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: asa-api-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,11 +18,13 @@ 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.
|
|
21
|
+
Requires-Dist: asa-api-client>=0.6.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
|
|
25
25
|
Requires-Dist: typer>=0.13.0
|
|
26
|
+
Provides-Extra: analyze
|
|
27
|
+
Requires-Dist: asa-api-client[cli]>=0.6.1; extra == 'analyze'
|
|
26
28
|
Provides-Extra: dev
|
|
27
29
|
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
28
30
|
Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
|
|
@@ -49,12 +51,18 @@ Using [uv](https://docs.astral.sh/uv/) (recommended):
|
|
|
49
51
|
|
|
50
52
|
```bash
|
|
51
53
|
uv tool install asa-api-cli
|
|
54
|
+
|
|
55
|
+
# Include Excel workbook analysis support (pandas + xlsxwriter)
|
|
56
|
+
uv tool install "asa-api-cli[analyze]"
|
|
52
57
|
```
|
|
53
58
|
|
|
54
59
|
Using pip:
|
|
55
60
|
|
|
56
61
|
```bash
|
|
57
62
|
pip install asa-api-cli
|
|
63
|
+
|
|
64
|
+
# Or with Excel workbook analysis support
|
|
65
|
+
pip install "asa-api-cli[analyze]"
|
|
58
66
|
```
|
|
59
67
|
|
|
60
68
|
Using pipx:
|
|
@@ -131,6 +139,16 @@ asa reports keywords 123456789 --start 2024-01-01 --end 2024-01-31
|
|
|
131
139
|
asa reports campaigns --start 2024-01-01 --end 2024-01-31 --output report.csv
|
|
132
140
|
```
|
|
133
141
|
|
|
142
|
+
### Analysis Workbook & Platform API v1 Smoke Test
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Generate an Excel performance analysis workbook (requires [analyze] extra)
|
|
146
|
+
asa analyze --period 30d --output asa-30d.xlsx
|
|
147
|
+
|
|
148
|
+
# Run a read-only smoke test against the Apple Ads Platform API v1
|
|
149
|
+
asa v1-smoke
|
|
150
|
+
```
|
|
151
|
+
|
|
134
152
|
### Brand Campaigns
|
|
135
153
|
|
|
136
154
|
Create brand protection campaigns for your app:
|
|
@@ -15,12 +15,18 @@ Using [uv](https://docs.astral.sh/uv/) (recommended):
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
uv tool install asa-api-cli
|
|
18
|
+
|
|
19
|
+
# Include Excel workbook analysis support (pandas + xlsxwriter)
|
|
20
|
+
uv tool install "asa-api-cli[analyze]"
|
|
18
21
|
```
|
|
19
22
|
|
|
20
23
|
Using pip:
|
|
21
24
|
|
|
22
25
|
```bash
|
|
23
26
|
pip install asa-api-cli
|
|
27
|
+
|
|
28
|
+
# Or with Excel workbook analysis support
|
|
29
|
+
pip install "asa-api-cli[analyze]"
|
|
24
30
|
```
|
|
25
31
|
|
|
26
32
|
Using pipx:
|
|
@@ -97,6 +103,16 @@ asa reports keywords 123456789 --start 2024-01-01 --end 2024-01-31
|
|
|
97
103
|
asa reports campaigns --start 2024-01-01 --end 2024-01-31 --output report.csv
|
|
98
104
|
```
|
|
99
105
|
|
|
106
|
+
### Analysis Workbook & Platform API v1 Smoke Test
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Generate an Excel performance analysis workbook (requires [analyze] extra)
|
|
110
|
+
asa analyze --period 30d --output asa-30d.xlsx
|
|
111
|
+
|
|
112
|
+
# Run a read-only smoke test against the Apple Ads Platform API v1
|
|
113
|
+
asa v1-smoke
|
|
114
|
+
```
|
|
115
|
+
|
|
100
116
|
### Brand Campaigns
|
|
101
117
|
|
|
102
118
|
Create brand protection campaigns for your app:
|
|
@@ -661,7 +661,7 @@ def review_negatives(
|
|
|
661
661
|
# Step 2: Fetch all ad groups
|
|
662
662
|
with spinner("Fetching ad groups..."):
|
|
663
663
|
ad_groups_resp = client.campaigns(campaign_id).ad_groups.find(
|
|
664
|
-
Selector(
|
|
664
|
+
Selector().where("status", "==", "ENABLED")
|
|
665
665
|
)
|
|
666
666
|
ad_groups = ad_groups_resp
|
|
667
667
|
|
|
@@ -887,9 +887,7 @@ def review_skag(
|
|
|
887
887
|
|
|
888
888
|
# Step 2: Fetch all ad groups
|
|
889
889
|
with spinner("Fetching ad groups..."):
|
|
890
|
-
ad_groups = client.campaigns(campaign_id).ad_groups.find(
|
|
891
|
-
Selector(conditions=[{"field": "status", "operator": "EQUALS", "values": ["ENABLED"]}])
|
|
892
|
-
)
|
|
890
|
+
ad_groups = client.campaigns(campaign_id).ad_groups.find(Selector().where("status", "==", "ENABLED"))
|
|
893
891
|
|
|
894
892
|
if not ad_groups:
|
|
895
893
|
print_warning("No enabled ad groups found in this campaign")
|
|
@@ -67,7 +67,16 @@ def test_auth(
|
|
|
67
67
|
table.add_column("Setting", style="label")
|
|
68
68
|
table.add_column("Status")
|
|
69
69
|
|
|
70
|
-
errors_by_field
|
|
70
|
+
errors_by_field: dict[str, str] = {}
|
|
71
|
+
model_errors: list[str] = []
|
|
72
|
+
for err in e.errors():
|
|
73
|
+
if err["loc"]:
|
|
74
|
+
errors_by_field[str(err["loc"][0])] = err["msg"]
|
|
75
|
+
else:
|
|
76
|
+
model_errors.append(err["msg"])
|
|
77
|
+
if "private_key" in err["msg"]:
|
|
78
|
+
errors_by_field.setdefault("private_key", err["msg"])
|
|
79
|
+
errors_by_field.setdefault("private_key_path", err["msg"])
|
|
71
80
|
|
|
72
81
|
for field in ["client_id", "team_id", "key_id", "org_id", "private_key", "private_key_path"]:
|
|
73
82
|
if field in errors_by_field:
|
|
@@ -77,7 +86,11 @@ def test_auth(
|
|
|
77
86
|
|
|
78
87
|
error_console.print(table)
|
|
79
88
|
error_console.print()
|
|
80
|
-
print_error(
|
|
89
|
+
print_error(
|
|
90
|
+
"Configuration Error",
|
|
91
|
+
"Missing or invalid settings",
|
|
92
|
+
details="\n".join(model_errors) if model_errors else None,
|
|
93
|
+
)
|
|
81
94
|
raise typer.Exit(EXIT_USAGE) from None
|
|
82
95
|
|
|
83
96
|
# Display loaded configuration
|
|
@@ -200,9 +213,12 @@ def show_config(
|
|
|
200
213
|
except ValidationError as e:
|
|
201
214
|
print_error("Configuration Error", "Could not load settings")
|
|
202
215
|
for err in e.errors():
|
|
203
|
-
field = err["loc"][0]
|
|
204
216
|
msg = err["msg"]
|
|
205
|
-
|
|
217
|
+
if err["loc"]:
|
|
218
|
+
field = err["loc"][0]
|
|
219
|
+
error_console.print(f" [error]ASA_{str(field).upper()}:[/error] {msg}")
|
|
220
|
+
else:
|
|
221
|
+
error_console.print(f" [error]Configuration:[/error] {msg}")
|
|
206
222
|
raise typer.Exit(EXIT_USAGE) from None
|
|
207
223
|
|
|
208
224
|
|
|
@@ -224,9 +224,12 @@ def analyze_impression_share(
|
|
|
224
224
|
poll_interval=3.0,
|
|
225
225
|
timeout=120.0,
|
|
226
226
|
)
|
|
227
|
-
except
|
|
227
|
+
except AppleSearchAdsError as e:
|
|
228
228
|
handle_api_error(e)
|
|
229
229
|
return
|
|
230
|
+
except (TimeoutError, RuntimeError) as e:
|
|
231
|
+
print_error("Report Error", str(e))
|
|
232
|
+
return
|
|
230
233
|
|
|
231
234
|
if not report.row:
|
|
232
235
|
print_warning("No impression share data available for the selected period")
|
|
@@ -359,9 +362,12 @@ def generate_share_report(
|
|
|
359
362
|
poll_interval=3.0,
|
|
360
363
|
timeout=120.0,
|
|
361
364
|
)
|
|
362
|
-
except
|
|
365
|
+
except AppleSearchAdsError as e:
|
|
363
366
|
handle_api_error(e)
|
|
364
367
|
return
|
|
368
|
+
except (TimeoutError, RuntimeError) as e:
|
|
369
|
+
print_error("Report Error", str(e))
|
|
370
|
+
return
|
|
365
371
|
|
|
366
372
|
if not report.row:
|
|
367
373
|
print_warning("No data available")
|
|
@@ -444,9 +450,12 @@ def share_summary(
|
|
|
444
450
|
poll_interval=3.0,
|
|
445
451
|
timeout=120.0,
|
|
446
452
|
)
|
|
447
|
-
except
|
|
453
|
+
except AppleSearchAdsError as e:
|
|
448
454
|
handle_api_error(e)
|
|
449
455
|
return
|
|
456
|
+
except (TimeoutError, RuntimeError) as e:
|
|
457
|
+
print_error("Report Error", str(e))
|
|
458
|
+
return
|
|
450
459
|
|
|
451
460
|
if not report.row:
|
|
452
461
|
print_warning("No data available")
|
|
@@ -221,7 +221,7 @@ def add_keyword(
|
|
|
221
221
|
{
|
|
222
222
|
"ID": str(keyword.id),
|
|
223
223
|
"Text": keyword.text,
|
|
224
|
-
"Match Type": keyword.match_type
|
|
224
|
+
"Match Type": enum_value(keyword.match_type),
|
|
225
225
|
},
|
|
226
226
|
)
|
|
227
227
|
print_hint(f"asa keywords list --campaign {campaign_id} --ad-group {ad_group_id}")
|
|
@@ -454,8 +454,8 @@ def list_negatives(
|
|
|
454
454
|
{
|
|
455
455
|
"id": n.id,
|
|
456
456
|
"text": n.text,
|
|
457
|
-
"match_type": n.match_type
|
|
458
|
-
"status": n.status
|
|
457
|
+
"match_type": enum_value(n.match_type),
|
|
458
|
+
"status": enum_value(n.status),
|
|
459
459
|
}
|
|
460
460
|
for n in negatives
|
|
461
461
|
]
|
|
@@ -519,7 +519,7 @@ def add_negative(
|
|
|
519
519
|
{
|
|
520
520
|
"Level": level.title(),
|
|
521
521
|
"Text": negative.text,
|
|
522
|
-
"Match Type": negative.match_type
|
|
522
|
+
"Match Type": enum_value(negative.match_type),
|
|
523
523
|
},
|
|
524
524
|
)
|
|
525
525
|
hint_args = f"--campaign {campaign_id}"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from typing import Annotated
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
|
+
from asa_api_client.cli import v1_smoke
|
|
6
7
|
|
|
7
8
|
from asa_api_cli import (
|
|
8
9
|
ad_groups,
|
|
@@ -21,7 +22,7 @@ from asa_api_cli import (
|
|
|
21
22
|
reports,
|
|
22
23
|
translate,
|
|
23
24
|
)
|
|
24
|
-
from asa_api_cli.utils import cli_state, error_console, init_consoles
|
|
25
|
+
from asa_api_cli.utils import EXIT_ERROR, cli_state, error_console, init_consoles, print_error
|
|
25
26
|
|
|
26
27
|
app = typer.Typer(
|
|
27
28
|
name="asa",
|
|
@@ -29,6 +30,38 @@ app = typer.Typer(
|
|
|
29
30
|
rich_markup_mode="rich",
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
|
|
34
|
+
def _register_analyze_command(target_app: typer.Typer) -> None:
|
|
35
|
+
"""Register ``asa analyze`` from asa-api-client, or a clean install-hint fallback."""
|
|
36
|
+
try:
|
|
37
|
+
from asa_api_client.cli.analyze import analyze as analyze_command
|
|
38
|
+
except ImportError:
|
|
39
|
+
|
|
40
|
+
@target_app.command(
|
|
41
|
+
"analyze",
|
|
42
|
+
help="Generate an Excel performance analysis workbook (requires [analyze] extra)",
|
|
43
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
44
|
+
)
|
|
45
|
+
def _analyze_missing_extra() -> None:
|
|
46
|
+
"""Generate an Excel performance analysis workbook.
|
|
47
|
+
|
|
48
|
+
Requires optional dependencies (pandas and xlsxwriter).
|
|
49
|
+
"""
|
|
50
|
+
print_error(
|
|
51
|
+
"Missing Optional Dependencies",
|
|
52
|
+
"The 'analyze' command requires pandas and xlsxwriter.",
|
|
53
|
+
details=('Install with: pip install "asa-api-cli[analyze]" or uv tool install "asa-api-cli[analyze]"'),
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(EXIT_ERROR)
|
|
56
|
+
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
target_app.command(
|
|
60
|
+
"analyze",
|
|
61
|
+
help="Generate an Excel performance analysis workbook",
|
|
62
|
+
)(analyze_command)
|
|
63
|
+
|
|
64
|
+
|
|
32
65
|
# Register sub-commands
|
|
33
66
|
app.add_typer(auth.app, name="auth", help="Authentication commands")
|
|
34
67
|
app.add_typer(brand.app, name="brand", help="Create brand protection campaigns")
|
|
@@ -45,6 +78,11 @@ app.add_typer(reports.app, name="reports", help="Generate reports")
|
|
|
45
78
|
app.add_typer(optimize.app, name="optimize", help="Optimization tools")
|
|
46
79
|
app.add_typer(impression_share.app, name="impression-share", help="Impression share analysis")
|
|
47
80
|
app.add_typer(translate.app, name="translate", help="Translate keywords to multiple languages")
|
|
81
|
+
_register_analyze_command(app)
|
|
82
|
+
app.command(
|
|
83
|
+
"v1-smoke",
|
|
84
|
+
help="Run a read-only smoke test against the Apple Ads Platform API v1",
|
|
85
|
+
)(v1_smoke.command)
|
|
48
86
|
|
|
49
87
|
|
|
50
88
|
def version_callback(value: bool) -> None:
|
|
@@ -1675,6 +1675,8 @@ def analyze_cpa_cap_impact(
|
|
|
1675
1675
|
currency = perf_30d.get("currency", "USD")
|
|
1676
1676
|
|
|
1677
1677
|
# Parse CPA goal
|
|
1678
|
+
if ag.cpa_goal is None:
|
|
1679
|
+
continue
|
|
1678
1680
|
cpa_goal_amount = ag.cpa_goal.amount
|
|
1679
1681
|
if hasattr(cpa_goal_amount, "amount"):
|
|
1680
1682
|
# Money object
|
|
@@ -1885,24 +1887,24 @@ def analyze_cpa_cap_impact(
|
|
|
1885
1887
|
console.print()
|
|
1886
1888
|
|
|
1887
1889
|
changes_made = 0
|
|
1888
|
-
for i,
|
|
1890
|
+
for i, analysis in enumerate(problem_groups, 1):
|
|
1889
1891
|
console.rule(f"[bold]{i}/{len(problem_groups)}")
|
|
1890
1892
|
console.print()
|
|
1891
1893
|
|
|
1892
1894
|
# Show ad group details
|
|
1893
|
-
impact_color = "red" if
|
|
1894
|
-
console.print(f"[bold]Ad Group:[/bold] {
|
|
1895
|
-
console.print(f"[bold]Campaign:[/bold] {
|
|
1895
|
+
impact_color = "red" if analysis.impact_level in ("BLOCKED", "CRITICAL") else "yellow"
|
|
1896
|
+
console.print(f"[bold]Ad Group:[/bold] {analysis.ad_group_name}")
|
|
1897
|
+
console.print(f"[bold]Campaign:[/bold] {analysis.campaign_name} ({analysis.country})")
|
|
1896
1898
|
console.print()
|
|
1897
|
-
cpa_str = f"{
|
|
1899
|
+
cpa_str = f"{analysis.cpa_goal:.2f} {analysis.currency}"
|
|
1898
1900
|
console.print(f" CPA Goal: [{impact_color}]{cpa_str}[/{impact_color}]")
|
|
1899
|
-
if
|
|
1900
|
-
console.print(f" Actual Avg CPA: {
|
|
1901
|
-
console.print(f" Impressions 7d: {
|
|
1902
|
-
console.print(f" Impressions 30d: {
|
|
1903
|
-
console.print(f" Taps 30d: {
|
|
1904
|
-
console.print(f" Installs 30d: {
|
|
1905
|
-
console.print(f" Impact: [{impact_color}]{
|
|
1901
|
+
if analysis.avg_cpa:
|
|
1902
|
+
console.print(f" Actual Avg CPA: {analysis.avg_cpa:.2f} {analysis.currency}")
|
|
1903
|
+
console.print(f" Impressions 7d: {analysis.impressions_7d:,}")
|
|
1904
|
+
console.print(f" Impressions 30d: {analysis.impressions_30d:,}")
|
|
1905
|
+
console.print(f" Taps 30d: {analysis.taps_30d:,}")
|
|
1906
|
+
console.print(f" Installs 30d: {analysis.installs_30d:,}")
|
|
1907
|
+
console.print(f" Impact: [{impact_color}]{analysis.impact_level}[/{impact_color}]")
|
|
1906
1908
|
console.print()
|
|
1907
1909
|
|
|
1908
1910
|
action = (
|
|
@@ -1928,20 +1930,20 @@ def analyze_cpa_cap_impact(
|
|
|
1928
1930
|
with spinner("Removing CPA goal..."):
|
|
1929
1931
|
# Update ad group with cpa_goal set to remove it
|
|
1930
1932
|
# Note: The API may require a specific way to clear the CPA goal
|
|
1931
|
-
client.campaigns(
|
|
1932
|
-
|
|
1933
|
+
client.campaigns(analysis.campaign_id).ad_groups.update(
|
|
1934
|
+
analysis.ad_group_id,
|
|
1933
1935
|
AdGroupUpdate(cpa_goal=None),
|
|
1934
1936
|
)
|
|
1935
|
-
print_success(f"Removed CPA goal from '{
|
|
1937
|
+
print_success(f"Removed CPA goal from '{analysis.ad_group_name}'")
|
|
1936
1938
|
changes_made += 1
|
|
1937
1939
|
except AppleSearchAdsError as e:
|
|
1938
1940
|
print_error("Failed to remove CPA goal", str(e))
|
|
1939
1941
|
console.print()
|
|
1940
1942
|
elif action in ("i", "increase"):
|
|
1941
1943
|
# Prompt for new CPA goal
|
|
1942
|
-
suggested =
|
|
1944
|
+
suggested = analysis.cpa_goal * Decimal("1.5") # Suggest 50% increase
|
|
1943
1945
|
new_goal_str = typer.prompt(
|
|
1944
|
-
f"New CPA goal ({
|
|
1946
|
+
f"New CPA goal ({analysis.currency})",
|
|
1945
1947
|
default=f"{suggested:.2f}",
|
|
1946
1948
|
)
|
|
1947
1949
|
try:
|
|
@@ -1953,13 +1955,17 @@ def analyze_cpa_cap_impact(
|
|
|
1953
1955
|
with spinner("Updating CPA goal..."):
|
|
1954
1956
|
from asa_api_client.models import CpaGoal as CpaGoalModel
|
|
1955
1957
|
|
|
1956
|
-
client.campaigns(
|
|
1957
|
-
|
|
1958
|
+
client.campaigns(analysis.campaign_id).ad_groups.update(
|
|
1959
|
+
analysis.ad_group_id,
|
|
1958
1960
|
AdGroupUpdate(
|
|
1959
|
-
cpa_goal=CpaGoalModel(
|
|
1961
|
+
cpa_goal=CpaGoalModel(
|
|
1962
|
+
amount=Money(amount=str(new_goal), currency=analysis.currency)
|
|
1963
|
+
)
|
|
1960
1964
|
),
|
|
1961
1965
|
)
|
|
1962
|
-
print_success(
|
|
1966
|
+
print_success(
|
|
1967
|
+
f"Updated CPA goal: {analysis.cpa_goal:.2f} → {new_goal:.2f} {analysis.currency}"
|
|
1968
|
+
)
|
|
1963
1969
|
changes_made += 1
|
|
1964
1970
|
except ValueError:
|
|
1965
1971
|
print_warning(f"Invalid input '{new_goal_str}', skipping")
|
|
@@ -24,6 +24,10 @@ LOCALE_COLUMNS = ["language_code", "language", "app_name", "device_classes"]
|
|
|
24
24
|
|
|
25
25
|
@app.command("list")
|
|
26
26
|
def list_product_pages(
|
|
27
|
+
adam_id: Annotated[
|
|
28
|
+
int,
|
|
29
|
+
typer.Option("--adam-id", "-a", help="App Store app ID (adamId)"),
|
|
30
|
+
],
|
|
27
31
|
limit: Annotated[
|
|
28
32
|
int,
|
|
29
33
|
typer.Option("--limit", "-l", help="Maximum number of results"),
|
|
@@ -33,18 +37,18 @@ def list_product_pages(
|
|
|
33
37
|
typer.Option("--format", "-f", help="Output format"),
|
|
34
38
|
] = OutputFormat.TABLE,
|
|
35
39
|
) -> None:
|
|
36
|
-
"""List custom product pages.
|
|
40
|
+
"""List custom product pages for an app.
|
|
37
41
|
|
|
38
42
|
Examples:
|
|
39
|
-
asa product-pages list
|
|
40
|
-
asa product-pages list --format json
|
|
43
|
+
asa product-pages list --adam-id 123456789
|
|
44
|
+
asa product-pages list -a 123456789 --format json
|
|
41
45
|
"""
|
|
42
46
|
client = get_client()
|
|
43
47
|
|
|
44
48
|
try:
|
|
45
49
|
with client:
|
|
46
50
|
with spinner("Fetching product pages..."):
|
|
47
|
-
result = client.product_pages.list(limit=limit)
|
|
51
|
+
result = client.product_pages(adam_id).list(limit=limit)
|
|
48
52
|
|
|
49
53
|
if not result.data:
|
|
50
54
|
print_warning("No product pages found")
|
|
@@ -73,18 +77,22 @@ def get_product_page(
|
|
|
73
77
|
str,
|
|
74
78
|
typer.Argument(help="Product page ID"),
|
|
75
79
|
],
|
|
80
|
+
adam_id: Annotated[
|
|
81
|
+
int,
|
|
82
|
+
typer.Option("--adam-id", "-a", help="App Store app ID (adamId)"),
|
|
83
|
+
],
|
|
76
84
|
) -> None:
|
|
77
85
|
"""Show details for a single custom product page.
|
|
78
86
|
|
|
79
87
|
Example:
|
|
80
|
-
asa product-pages get <product-page-id>
|
|
88
|
+
asa product-pages get <product-page-id> --adam-id 123456789
|
|
81
89
|
"""
|
|
82
90
|
client = get_client()
|
|
83
91
|
|
|
84
92
|
try:
|
|
85
93
|
with client:
|
|
86
94
|
with spinner("Fetching product page..."):
|
|
87
|
-
pp = client.product_pages.get(product_page_id)
|
|
95
|
+
pp = client.product_pages(adam_id).get(product_page_id)
|
|
88
96
|
|
|
89
97
|
print_result_panel(
|
|
90
98
|
f"Product page {pp.id}",
|
|
@@ -106,6 +114,10 @@ def product_page_locales(
|
|
|
106
114
|
str,
|
|
107
115
|
typer.Argument(help="Product page ID"),
|
|
108
116
|
],
|
|
117
|
+
adam_id: Annotated[
|
|
118
|
+
int,
|
|
119
|
+
typer.Option("--adam-id", "-a", help="App Store app ID (adamId)"),
|
|
120
|
+
],
|
|
109
121
|
format: Annotated[
|
|
110
122
|
OutputFormat,
|
|
111
123
|
typer.Option("--format", "-f", help="Output format"),
|
|
@@ -114,14 +126,14 @@ def product_page_locales(
|
|
|
114
126
|
"""List the per-locale details of a custom product page.
|
|
115
127
|
|
|
116
128
|
Example:
|
|
117
|
-
asa product-pages locales <product-page-id>
|
|
129
|
+
asa product-pages locales <product-page-id> --adam-id 123456789
|
|
118
130
|
"""
|
|
119
131
|
client = get_client()
|
|
120
132
|
|
|
121
133
|
try:
|
|
122
134
|
with client:
|
|
123
135
|
with spinner("Fetching locale details..."):
|
|
124
|
-
details = client.product_pages.get_locale_details(product_page_id)
|
|
136
|
+
details = client.product_pages(adam_id).get_locale_details(product_page_id)
|
|
125
137
|
|
|
126
138
|
if not details:
|
|
127
139
|
print_warning("No locale details found")
|
|
@@ -122,7 +122,7 @@ def handle_api_error(e: AppleSearchAdsError) -> None:
|
|
|
122
122
|
Args:
|
|
123
123
|
e: The exception to handle.
|
|
124
124
|
"""
|
|
125
|
-
from asa_api_client.exceptions import ValidationError
|
|
125
|
+
from asa_api_client.exceptions import PartialFailureError, ValidationError
|
|
126
126
|
|
|
127
127
|
details_parts = []
|
|
128
128
|
|
|
@@ -139,10 +139,18 @@ def handle_api_error(e: AppleSearchAdsError) -> None:
|
|
|
139
139
|
for err in errors:
|
|
140
140
|
details_parts.append(f" {field}: {err}")
|
|
141
141
|
|
|
142
|
+
# Show per-item error entries for partial failures (2xx with error block)
|
|
143
|
+
if isinstance(e, PartialFailureError) and e.details:
|
|
144
|
+
for item in e.details:
|
|
145
|
+
field_name = item.get("field")
|
|
146
|
+
code = item.get("messageCode") or item.get("code")
|
|
147
|
+
msg = item.get("message") or code or json.dumps(item)
|
|
148
|
+
prefix = f"{field_name}: " if field_name else ""
|
|
149
|
+
suffix = f" ({code})" if code and code != msg else ""
|
|
150
|
+
details_parts.append(f" {prefix}{msg}{suffix}")
|
|
151
|
+
|
|
142
152
|
# Show response body if available (for debugging)
|
|
143
153
|
if e.response_body:
|
|
144
|
-
import json
|
|
145
|
-
|
|
146
154
|
# Remove internal _request field before displaying
|
|
147
155
|
body_copy = {k: v for k, v in e.response_body.items() if not k.startswith("_")}
|
|
148
156
|
body_str = json.dumps(body_copy, indent=2)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "asa-api-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Command-line interface for Apple Search Ads campaign management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -28,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Typing :: Typed",
|
|
29
29
|
]
|
|
30
30
|
dependencies = [
|
|
31
|
-
"asa-api-client>=0.
|
|
31
|
+
"asa-api-client>=0.6.1",
|
|
32
32
|
"typer>=0.13.0",
|
|
33
33
|
"rich>=13.9.0",
|
|
34
34
|
"pydantic-ai>=0.1.0",
|
|
@@ -39,6 +39,9 @@ dependencies = [
|
|
|
39
39
|
asa = "asa_api_cli:app"
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
|
+
analyze = [
|
|
43
|
+
"asa-api-client[cli]>=0.6.1",
|
|
44
|
+
]
|
|
42
45
|
dev = [
|
|
43
46
|
"pytest>=8.3.0",
|
|
44
47
|
"pytest-asyncio>=0.24.0",
|
|
@@ -64,6 +67,7 @@ python_version = "3.13"
|
|
|
64
67
|
strict = true
|
|
65
68
|
ignore_missing_imports = true
|
|
66
69
|
warn_unused_ignores = false
|
|
70
|
+
plugins = ["pydantic.mypy"]
|
|
67
71
|
|
|
68
72
|
[tool.ruff]
|
|
69
73
|
target-version = "py313"
|