asa-api-client 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 (154) hide show
  1. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/ci.yml +5 -2
  2. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/docs.yml +5 -5
  3. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/publish.yml +1 -1
  4. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/PKG-INFO +49 -7
  5. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/README.md +37 -5
  6. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/__init__.py +5 -1
  7. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/auth.py +8 -4
  8. asa_api_client-0.3.0/asa_api_client/cli/__init__.py +20 -0
  9. asa_api_client-0.3.0/asa_api_client/cli/analyze.py +235 -0
  10. asa_api_client-0.3.0/asa_api_client/cli/dates.py +95 -0
  11. asa_api_client-0.3.0/asa_api_client/cli/fetch.py +464 -0
  12. asa_api_client-0.3.0/asa_api_client/cli/metrics.py +197 -0
  13. asa_api_client-0.3.0/asa_api_client/cli/v1_adapter.py +625 -0
  14. asa_api_client-0.3.0/asa_api_client/cli/v1_smoke.py +396 -0
  15. asa_api_client-0.3.0/asa_api_client/cli/workbook.py +444 -0
  16. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/exceptions.py +33 -0
  17. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/apps.py +1 -3
  18. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/campaigns.py +1 -3
  19. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/reports.py +1 -3
  20. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/base.py +2 -2
  21. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/product_pages.py +1 -3
  22. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/reports.py +49 -7
  23. asa_api_client-0.3.0/asa_api_client/v1/__init__.py +16 -0
  24. asa_api_client-0.3.0/asa_api_client/v1/client.py +521 -0
  25. asa_api_client-0.3.0/asa_api_client/v1/models/__init__.py +5 -0
  26. asa_api_client-0.3.0/asa_api_client/v1/models/ad_accounts.py +352 -0
  27. asa_api_client-0.3.0/asa_api_client/v1/models/ad_groups.py +301 -0
  28. asa_api_client-0.3.0/asa_api_client/v1/models/ads.py +204 -0
  29. asa_api_client-0.3.0/asa_api_client/v1/models/apps.py +193 -0
  30. asa_api_client-0.3.0/asa_api_client/v1/models/base.py +149 -0
  31. asa_api_client-0.3.0/asa_api_client/v1/models/brands.py +395 -0
  32. asa_api_client-0.3.0/asa_api_client/v1/models/budget_orders.py +283 -0
  33. asa_api_client-0.3.0/asa_api_client/v1/models/bulk.py +171 -0
  34. asa_api_client-0.3.0/asa_api_client/v1/models/campaigns.py +501 -0
  35. asa_api_client-0.3.0/asa_api_client/v1/models/change_history.py +245 -0
  36. asa_api_client-0.3.0/asa_api_client/v1/models/creatives.py +426 -0
  37. asa_api_client-0.3.0/asa_api_client/v1/models/geo.py +164 -0
  38. asa_api_client-0.3.0/asa_api_client/v1/models/insights.py +300 -0
  39. asa_api_client-0.3.0/asa_api_client/v1/models/keywords.py +195 -0
  40. asa_api_client-0.3.0/asa_api_client/v1/models/product_pages.py +140 -0
  41. asa_api_client-0.3.0/asa_api_client/v1/models/recommendations.py +642 -0
  42. asa_api_client-0.3.0/asa_api_client/v1/models/reports.py +1854 -0
  43. asa_api_client-0.3.0/asa_api_client/v1/models/suggestions.py +98 -0
  44. asa_api_client-0.3.0/asa_api_client/v1/query.py +177 -0
  45. asa_api_client-0.3.0/asa_api_client/v1/resources/__init__.py +1 -0
  46. asa_api_client-0.3.0/asa_api_client/v1/resources/ad_accounts.py +217 -0
  47. asa_api_client-0.3.0/asa_api_client/v1/resources/ad_groups.py +61 -0
  48. asa_api_client-0.3.0/asa_api_client/v1/resources/ads.py +46 -0
  49. asa_api_client-0.3.0/asa_api_client/v1/resources/apps.py +325 -0
  50. asa_api_client-0.3.0/asa_api_client/v1/resources/base.py +803 -0
  51. asa_api_client-0.3.0/asa_api_client/v1/resources/brands.py +139 -0
  52. asa_api_client-0.3.0/asa_api_client/v1/resources/budget_orders.py +71 -0
  53. asa_api_client-0.3.0/asa_api_client/v1/resources/bulk.py +322 -0
  54. asa_api_client-0.3.0/asa_api_client/v1/resources/campaigns.py +90 -0
  55. asa_api_client-0.3.0/asa_api_client/v1/resources/change_history.py +194 -0
  56. asa_api_client-0.3.0/asa_api_client/v1/resources/creatives.py +206 -0
  57. asa_api_client-0.3.0/asa_api_client/v1/resources/geo.py +196 -0
  58. asa_api_client-0.3.0/asa_api_client/v1/resources/insights.py +141 -0
  59. asa_api_client-0.3.0/asa_api_client/v1/resources/keywords.py +91 -0
  60. asa_api_client-0.3.0/asa_api_client/v1/resources/product_pages.py +141 -0
  61. asa_api_client-0.3.0/asa_api_client/v1/resources/recommendations.py +339 -0
  62. asa_api_client-0.3.0/asa_api_client/v1/resources/reports.py +401 -0
  63. asa_api_client-0.3.0/asa_api_client/v1/resources/suggestions.py +194 -0
  64. asa_api_client-0.3.0/docs/guide/cli.md +48 -0
  65. asa_api_client-0.3.0/docs/guide/platform-api-v1.md +154 -0
  66. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/index.md +1 -1
  67. asa_api_client-0.3.0/docs/superpowers/plans/2026-08-05-analyze-cli.md +2587 -0
  68. asa_api_client-0.3.0/docs/superpowers/plans/2026-08-16-apple-ads-platform-api-v1.md +403 -0
  69. asa_api_client-0.3.0/docs/superpowers/specs/2026-08-05-analyze-cli-design.md +144 -0
  70. asa_api_client-0.3.0/docs/superpowers/specs/2026-08-16-apple-ads-platform-api-v1-design.md +149 -0
  71. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/mkdocs.yml +2 -0
  72. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/pyproject.toml +19 -1
  73. asa_api_client-0.3.0/tests/unit/cli/__init__.py +0 -0
  74. asa_api_client-0.3.0/tests/unit/cli/conftest.py +99 -0
  75. asa_api_client-0.3.0/tests/unit/cli/test_cli.py +112 -0
  76. asa_api_client-0.3.0/tests/unit/cli/test_dates.py +90 -0
  77. asa_api_client-0.3.0/tests/unit/cli/test_entry.py +45 -0
  78. asa_api_client-0.3.0/tests/unit/cli/test_fetch.py +364 -0
  79. asa_api_client-0.3.0/tests/unit/cli/test_metrics.py +202 -0
  80. asa_api_client-0.3.0/tests/unit/cli/test_v1_adapter.py +429 -0
  81. asa_api_client-0.3.0/tests/unit/cli/test_v1_smoke.py +245 -0
  82. asa_api_client-0.3.0/tests/unit/cli/test_workbook.py +225 -0
  83. asa_api_client-0.3.0/tests/unit/v1/__init__.py +0 -0
  84. asa_api_client-0.3.0/tests/unit/v1/conftest.py +31 -0
  85. asa_api_client-0.3.0/tests/unit/v1/test_ad_accounts.py +409 -0
  86. asa_api_client-0.3.0/tests/unit/v1/test_ad_groups.py +359 -0
  87. asa_api_client-0.3.0/tests/unit/v1/test_ads.py +264 -0
  88. asa_api_client-0.3.0/tests/unit/v1/test_apps.py +426 -0
  89. asa_api_client-0.3.0/tests/unit/v1/test_brands.py +496 -0
  90. asa_api_client-0.3.0/tests/unit/v1/test_budget_orders.py +336 -0
  91. asa_api_client-0.3.0/tests/unit/v1/test_bulk.py +474 -0
  92. asa_api_client-0.3.0/tests/unit/v1/test_campaigns.py +394 -0
  93. asa_api_client-0.3.0/tests/unit/v1/test_change_history.py +333 -0
  94. asa_api_client-0.3.0/tests/unit/v1/test_client.py +155 -0
  95. asa_api_client-0.3.0/tests/unit/v1/test_creatives_assets.py +476 -0
  96. asa_api_client-0.3.0/tests/unit/v1/test_exceptions.py +18 -0
  97. asa_api_client-0.3.0/tests/unit/v1/test_geo.py +313 -0
  98. asa_api_client-0.3.0/tests/unit/v1/test_insights.py +414 -0
  99. asa_api_client-0.3.0/tests/unit/v1/test_keywords.py +351 -0
  100. asa_api_client-0.3.0/tests/unit/v1/test_models_base.py +148 -0
  101. asa_api_client-0.3.0/tests/unit/v1/test_product_pages.py +302 -0
  102. asa_api_client-0.3.0/tests/unit/v1/test_query.py +113 -0
  103. asa_api_client-0.3.0/tests/unit/v1/test_recommendations.py +586 -0
  104. asa_api_client-0.3.0/tests/unit/v1/test_reports.py +639 -0
  105. asa_api_client-0.3.0/tests/unit/v1/test_resource_base.py +391 -0
  106. asa_api_client-0.3.0/tests/unit/v1/test_suggestions.py +305 -0
  107. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/uv.lock +203 -83
  108. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  109. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  110. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  111. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/dependabot.yml +0 -0
  112. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.gitignore +0 -0
  113. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.pre-commit-config.yaml +0 -0
  114. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/LICENSE +0 -0
  115. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/client.py +0 -0
  116. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/logging.py +0 -0
  117. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/__init__.py +0 -0
  118. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/acls.py +0 -0
  119. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/ad_groups.py +0 -0
  120. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/ads.py +0 -0
  121. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/base.py +0 -0
  122. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/budget_orders.py +0 -0
  123. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/countries.py +0 -0
  124. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/geo.py +0 -0
  125. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/keywords.py +0 -0
  126. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/product_pages.py +0 -0
  127. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/__init__.py +0 -0
  128. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/acls.py +0 -0
  129. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/ad_groups.py +0 -0
  130. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/ads.py +0 -0
  131. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/apps.py +0 -0
  132. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/budget_orders.py +0 -0
  133. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/campaigns.py +0 -0
  134. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/countries.py +0 -0
  135. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/custom_reports.py +0 -0
  136. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/geo.py +0 -0
  137. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/keywords.py +0 -0
  138. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/settings.py +0 -0
  139. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/utils/__init__.py +0 -0
  140. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/CNAME +0 -0
  141. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/client.md +0 -0
  142. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/exceptions.md +0 -0
  143. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/models.md +0 -0
  144. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/authentication.md +0 -0
  145. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/installation.md +0 -0
  146. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/quickstart.md +0 -0
  147. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/ad-groups.md +0 -0
  148. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/async.md +0 -0
  149. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/campaigns.md +0 -0
  150. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/keywords.md +0 -0
  151. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/reports.md +0 -0
  152. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/tests/__init__.py +0 -0
  153. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/tests/unit/__init__.py +0 -0
  154. {asa_api_client-0.2.0 → asa_api_client-0.3.0}/tests/unit/test_models.py +0 -0
@@ -14,7 +14,7 @@ jobs:
14
14
  python-version: ["3.13"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v6
17
+ - uses: actions/checkout@v7
18
18
 
19
19
  - name: Install uv
20
20
  uses: astral-sh/setup-uv@v7
@@ -24,6 +24,9 @@ jobs:
24
24
  - name: Set up Python ${{ matrix.python-version }}
25
25
  run: uv python install ${{ matrix.python-version }}
26
26
 
27
+ - name: Check uv.lock is up to date
28
+ run: uv lock --check
29
+
27
30
  - name: Install dependencies
28
31
  run: uv sync --extra dev
29
32
 
@@ -40,7 +43,7 @@ jobs:
40
43
  run: uv run pytest --cov=asa_api_client --cov-report=xml
41
44
 
42
45
  - name: Upload coverage to Codecov
43
- uses: codecov/codecov-action@v5
46
+ uses: codecov/codecov-action@v7
44
47
  with:
45
48
  file: ./coverage.xml
46
49
  fail_ci_if_error: false
@@ -18,10 +18,10 @@ jobs:
18
18
  build:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@v7
22
22
 
23
23
  - name: Install uv
24
- uses: astral-sh/setup-uv@v4
24
+ uses: astral-sh/setup-uv@v7
25
25
  with:
26
26
  version: "latest"
27
27
 
@@ -35,10 +35,10 @@ jobs:
35
35
  run: uv run mkdocs build
36
36
 
37
37
  - name: Setup Pages
38
- uses: actions/configure-pages@v4
38
+ uses: actions/configure-pages@v6
39
39
 
40
40
  - name: Upload artifact
41
- uses: actions/upload-pages-artifact@v3
41
+ uses: actions/upload-pages-artifact@v5
42
42
  with:
43
43
  path: site
44
44
 
@@ -51,4 +51,4 @@ jobs:
51
51
  steps:
52
52
  - name: Deploy to GitHub Pages
53
53
  id: deployment
54
- uses: actions/deploy-pages@v4
54
+ uses: actions/deploy-pages@v5
@@ -12,7 +12,7 @@ jobs:
12
12
  id-token: write # Required for trusted publishing
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v6
15
+ - uses: actions/checkout@v7
16
16
 
17
17
  - name: Install uv
18
18
  uses: astral-sh/setup-uv@v7
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: asa-api-client
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A modern Python client for the Apple Search Ads API with full type safety and async support
5
5
  Project-URL: Homepage, https://github.com/SamPetherbridge/asa-api-client
6
6
  Project-URL: Documentation, https://asa-api-client.peth.au
@@ -23,8 +23,14 @@ Requires-Dist: httpx>=0.27.0
23
23
  Requires-Dist: pydantic-settings>=2.6.0
24
24
  Requires-Dist: pydantic>=2.10.0
25
25
  Requires-Dist: pyjwt[crypto]>=2.9.0
26
+ Provides-Extra: cli
27
+ Requires-Dist: pandas>=2.2.0; extra == 'cli'
28
+ Requires-Dist: rich>=13.0.0; extra == 'cli'
29
+ Requires-Dist: typer>=0.15.0; extra == 'cli'
30
+ Requires-Dist: xlsxwriter>=3.2.0; extra == 'cli'
26
31
  Provides-Extra: dev
27
32
  Requires-Dist: mypy>=1.13.0; extra == 'dev'
33
+ Requires-Dist: openpyxl>=3.1.0; extra == 'dev'
28
34
  Requires-Dist: pandas-stubs>=2.2.0; extra == 'dev'
29
35
  Requires-Dist: pandas>=2.2.0; extra == 'dev'
30
36
  Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
@@ -32,7 +38,11 @@ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
32
38
  Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
33
39
  Requires-Dist: pytest-httpx>=0.34.0; extra == 'dev'
34
40
  Requires-Dist: pytest>=8.3.0; extra == 'dev'
41
+ Requires-Dist: rich>=13.0.0; extra == 'dev'
35
42
  Requires-Dist: ruff>=0.8.0; extra == 'dev'
43
+ Requires-Dist: typer>=0.15.0; extra == 'dev'
44
+ Requires-Dist: types-openpyxl>=3.1.0; extra == 'dev'
45
+ Requires-Dist: xlsxwriter>=3.2.0; extra == 'dev'
36
46
  Provides-Extra: docs
37
47
  Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
38
48
  Requires-Dist: mkdocs>=1.6.0; extra == 'docs'
@@ -48,7 +58,7 @@ Description-Content-Type: text/markdown
48
58
  [![License](https://img.shields.io/github/license/SamPetherbridge/asa-api-client.svg)](https://github.com/SamPetherbridge/asa-api-client/blob/main/LICENSE)
49
59
  [![CI](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml)
50
60
 
51
- A modern, fully-typed Python client for the Apple Search Ads API with async support and Pydantic models.
61
+ A modern, fully-typed Python client for the Apple Ads APIs with async support and Pydantic models — covering both the new **Apple Ads Platform API v1** and the legacy Campaign Management API v5 (sunset January 26, 2027).
52
62
 
53
63
  ## Features
54
64
 
@@ -105,6 +115,35 @@ with client:
105
115
  print(f"{campaign.name}: {campaign.status}")
106
116
  ```
107
117
 
118
+ ## Platform API v1
119
+
120
+ Apple's new [Platform API v1](https://developer.apple.com/documentation/apple-ads-platform-api) replaces v5 (which stops working on **2027-01-26**). The v1 client ships alongside the v5 client with the same credentials — just add your ad account ID:
121
+
122
+ ```python
123
+ from asa_api_client import AppleAdsClient
124
+ from asa_api_client.v1 import Query
125
+
126
+ client = AppleAdsClient.from_env() # reads ASA_* plus ASA_AD_ACCOUNT_ID
127
+
128
+ with client:
129
+ # Flat query-based API with typed filters
130
+ enabled = client.campaigns.query(Query().where("status", "EQUALS", "ENABLED"))
131
+
132
+ # v1-only features
133
+ recs = client.recommendations
134
+ popularity = client.insights
135
+ history = client.change_history
136
+ bulk = client.bulk
137
+ ```
138
+
139
+ Don't know your ad account ID? Run the read-only smoke check — it discovers accounts and validates the whole integration against the live API:
140
+
141
+ ```bash
142
+ asa v1-smoke
143
+ ```
144
+
145
+ See the [Platform API v1 guide](https://asa-api-client.peth.au/guide/platform-api-v1/) for the full migration crib and resource reference. Import v1 symbols from the package root (`AppleAdsClient`) — internal layout may change when v5 is removed.
146
+
108
147
  ## Environment Variables
109
148
 
110
149
  ```bash
@@ -113,6 +152,7 @@ export ASA_TEAM_ID="SEARCHADS.your-team-id"
113
152
  export ASA_KEY_ID="your-key-id"
114
153
  export ASA_ORG_ID="123456"
115
154
  export ASA_PRIVATE_KEY_PATH="/path/to/private-key.pem"
155
+ export ASA_AD_ACCOUNT_ID="123456" # Platform API v1 only
116
156
  ```
117
157
 
118
158
  Or use a `.env` file:
@@ -123,6 +163,7 @@ ASA_TEAM_ID=SEARCHADS.your-team-id
123
163
  ASA_KEY_ID=your-key-id
124
164
  ASA_ORG_ID=123456
125
165
  ASA_PRIVATE_KEY_PATH=private-key.pem
166
+ ASA_AD_ACCOUNT_ID=123456 # Platform API v1 only
126
167
  ```
127
168
 
128
169
  ## Resources
@@ -223,14 +264,15 @@ asyncio.run(main())
223
264
 
224
265
  ## CLI
225
266
 
226
- For a command-line interface, install [asa-api-cli](https://github.com/SamPetherbridge/asa-api-cli):
267
+ Generate a formatted Excel analysis workbook with the included `asa analyze` CLI:
227
268
 
228
269
  ```bash
229
- uv tool install asa-api-cli
230
- # or
231
- pip install asa-api-cli
270
+ pip install "asa-api-client[cli]"
271
+ asa analyze --period 90d --output report.xlsx
232
272
  ```
233
273
 
274
+ The workbook includes a summary sheet with KPIs and trends, formatted analysis sheets per reporting level, and raw daily data for pivoting. For details, see the [CLI guide](https://asa-api-client.peth.au/guide/cli/).
275
+
234
276
  ## License
235
277
 
236
278
  MIT License - Copyright (c) 2025 Peth Pty Ltd
@@ -5,7 +5,7 @@
5
5
  [![License](https://img.shields.io/github/license/SamPetherbridge/asa-api-client.svg)](https://github.com/SamPetherbridge/asa-api-client/blob/main/LICENSE)
6
6
  [![CI](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml)
7
7
 
8
- A modern, fully-typed Python client for the Apple Search Ads API with async support and Pydantic models.
8
+ A modern, fully-typed Python client for the Apple Ads APIs with async support and Pydantic models — covering both the new **Apple Ads Platform API v1** and the legacy Campaign Management API v5 (sunset January 26, 2027).
9
9
 
10
10
  ## Features
11
11
 
@@ -62,6 +62,35 @@ with client:
62
62
  print(f"{campaign.name}: {campaign.status}")
63
63
  ```
64
64
 
65
+ ## Platform API v1
66
+
67
+ Apple's new [Platform API v1](https://developer.apple.com/documentation/apple-ads-platform-api) replaces v5 (which stops working on **2027-01-26**). The v1 client ships alongside the v5 client with the same credentials — just add your ad account ID:
68
+
69
+ ```python
70
+ from asa_api_client import AppleAdsClient
71
+ from asa_api_client.v1 import Query
72
+
73
+ client = AppleAdsClient.from_env() # reads ASA_* plus ASA_AD_ACCOUNT_ID
74
+
75
+ with client:
76
+ # Flat query-based API with typed filters
77
+ enabled = client.campaigns.query(Query().where("status", "EQUALS", "ENABLED"))
78
+
79
+ # v1-only features
80
+ recs = client.recommendations
81
+ popularity = client.insights
82
+ history = client.change_history
83
+ bulk = client.bulk
84
+ ```
85
+
86
+ Don't know your ad account ID? Run the read-only smoke check — it discovers accounts and validates the whole integration against the live API:
87
+
88
+ ```bash
89
+ asa v1-smoke
90
+ ```
91
+
92
+ See the [Platform API v1 guide](https://asa-api-client.peth.au/guide/platform-api-v1/) for the full migration crib and resource reference. Import v1 symbols from the package root (`AppleAdsClient`) — internal layout may change when v5 is removed.
93
+
65
94
  ## Environment Variables
66
95
 
67
96
  ```bash
@@ -70,6 +99,7 @@ export ASA_TEAM_ID="SEARCHADS.your-team-id"
70
99
  export ASA_KEY_ID="your-key-id"
71
100
  export ASA_ORG_ID="123456"
72
101
  export ASA_PRIVATE_KEY_PATH="/path/to/private-key.pem"
102
+ export ASA_AD_ACCOUNT_ID="123456" # Platform API v1 only
73
103
  ```
74
104
 
75
105
  Or use a `.env` file:
@@ -80,6 +110,7 @@ ASA_TEAM_ID=SEARCHADS.your-team-id
80
110
  ASA_KEY_ID=your-key-id
81
111
  ASA_ORG_ID=123456
82
112
  ASA_PRIVATE_KEY_PATH=private-key.pem
113
+ ASA_AD_ACCOUNT_ID=123456 # Platform API v1 only
83
114
  ```
84
115
 
85
116
  ## Resources
@@ -180,14 +211,15 @@ asyncio.run(main())
180
211
 
181
212
  ## CLI
182
213
 
183
- For a command-line interface, install [asa-api-cli](https://github.com/SamPetherbridge/asa-api-cli):
214
+ Generate a formatted Excel analysis workbook with the included `asa analyze` CLI:
184
215
 
185
216
  ```bash
186
- uv tool install asa-api-cli
187
- # or
188
- pip install asa-api-cli
217
+ pip install "asa-api-client[cli]"
218
+ asa analyze --period 90d --output report.xlsx
189
219
  ```
190
220
 
221
+ The workbook includes a summary sheet with KPIs and trends, formatted analysis sheets per reporting level, and raw daily data for pivoting. For details, see the [CLI guide](https://asa-api-client.peth.au/guide/cli/).
222
+
191
223
  ## License
192
224
 
193
225
  MIT License - Copyright (c) 2025 Peth Pty Ltd
@@ -29,22 +29,26 @@ from asa_api_client.exceptions import (
29
29
  AuthenticationError,
30
30
  ConfigurationError,
31
31
  NotFoundError,
32
+ PartialFailureError,
32
33
  RateLimitError,
33
34
  ValidationError,
34
35
  )
35
36
  from asa_api_client.logging import configure_logging
36
37
  from asa_api_client.settings import Settings
38
+ from asa_api_client.v1.client import AppleAdsClient
37
39
 
38
40
  __all__ = [
41
+ "AppleAdsClient",
39
42
  "AppleSearchAdsClient",
40
43
  "AppleSearchAdsError",
41
44
  "AuthenticationError",
42
45
  "ConfigurationError",
43
46
  "NotFoundError",
47
+ "PartialFailureError",
44
48
  "RateLimitError",
45
49
  "Settings",
46
50
  "ValidationError",
47
51
  "configure_logging",
48
52
  ]
49
53
 
50
- __version__ = "0.2.0"
54
+ __version__ = "0.3.0"
@@ -446,11 +446,15 @@ class Authenticator:
446
446
  errors = e.errors()
447
447
  if errors:
448
448
  first_error = errors[0]
449
- field = first_error.get("loc", ["unknown"])[0]
449
+ # Model-level validators (e.g. the private-key source check)
450
+ # report an empty loc tuple.
451
+ loc = first_error.get("loc") or ()
450
452
  msg = first_error.get("msg", "validation error")
451
- raise ConfigurationError(
452
- f"Configuration error for ASA_{str(field).upper()}: {msg}"
453
- ) from e
453
+ if loc:
454
+ raise ConfigurationError(
455
+ f"Configuration error for ASA_{str(loc[0]).upper()}: {msg}"
456
+ ) from e
457
+ raise ConfigurationError(f"Configuration error: {msg}") from e
454
458
  raise ConfigurationError(f"Configuration error: {e}") from e
455
459
 
456
460
  return cls(
@@ -0,0 +1,20 @@
1
+ """Console entry point for the ``asa`` CLI.
2
+
3
+ The CLI's dependencies (typer, rich, pandas, xlsxwriter) are an optional
4
+ extra. ``main`` guards the import so a bare install gets a clean install
5
+ hint instead of a traceback.
6
+ """
7
+
8
+ import sys
9
+
10
+ _INSTALL_HINT = 'Install the CLI extra: pip install "asa-api-client[cli]"'
11
+
12
+
13
+ def main() -> None:
14
+ """Run the ``asa`` command-line interface."""
15
+ try:
16
+ from asa_api_client.cli.analyze import app
17
+ except ImportError:
18
+ sys.stderr.write(_INSTALL_HINT + "\n")
19
+ raise SystemExit(1) from None
20
+ app()
@@ -0,0 +1,235 @@
1
+ """The ``asa analyze`` command: options, orchestration, presentation."""
2
+
3
+ import asyncio
4
+ from datetime import UTC, date, datetime
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import pandas as pd
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.progress import BarColumn, Progress, TaskID, TextColumn
13
+ from xlsxwriter.exceptions import XlsxWriterException
14
+
15
+ from asa_api_client.cli import dates, fetch, metrics, v1_smoke
16
+ from asa_api_client.cli.v1_adapter import V1FetchAdapter
17
+ from asa_api_client.cli.workbook import SummaryData, write_workbook
18
+ from asa_api_client.client import AppleSearchAdsClient
19
+ from asa_api_client.exceptions import AppleSearchAdsError, ConfigurationError
20
+ from asa_api_client.v1.client import AppleAdsClient
21
+
22
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
23
+ app.command("v1-smoke")(v1_smoke.command)
24
+ _err = Console(stderr=True)
25
+
26
+
27
+ @app.callback()
28
+ def _root() -> None:
29
+ """Apple Search Ads analysis toolkit."""
30
+
31
+
32
+ class Period(StrEnum):
33
+ """Preset reporting periods."""
34
+
35
+ D30 = "30d"
36
+ D90 = "90d"
37
+ D365 = "365d"
38
+
39
+
40
+ class ApiVersion(StrEnum):
41
+ """Selectable Apple Ads API backends."""
42
+
43
+ V5 = "v5"
44
+ V1 = "v1"
45
+
46
+
47
+ _CURRENCY_FORMATS = {
48
+ "USD": "$#,##0.00",
49
+ "AUD": "$#,##0.00",
50
+ "CAD": "$#,##0.00",
51
+ "NZD": "$#,##0.00",
52
+ "EUR": "€#,##0.00",
53
+ "GBP": "£#,##0.00",
54
+ "JPY": "¥#,##0",
55
+ }
56
+
57
+
58
+ def _currency_format(code: str | None) -> str:
59
+ """Map an ISO currency code to an Excel number format."""
60
+ if code is None:
61
+ return "$#,##0.00"
62
+ return _CURRENCY_FORMATS.get(code, f'"{code} "#,##0.00')
63
+
64
+
65
+ def _currency_symbol(currency_format: str) -> str:
66
+ """First non-format character of the currency format, for the headline."""
67
+ head = currency_format[0]
68
+ return head if head not in '#0["' else "$"
69
+
70
+
71
+ def _fail(message: str) -> "typer.Exit":
72
+ """Print a clean one-line error and exit 1."""
73
+ _err.print(f"[red]Error:[/red] {message}")
74
+ return typer.Exit(code=1)
75
+
76
+
77
+ async def _fetch(
78
+ client: fetch.FetchClient,
79
+ meta: pd.DataFrame,
80
+ start: date,
81
+ end: date,
82
+ timezone: str,
83
+ progress: Progress,
84
+ ) -> fetch.FetchResult:
85
+ """Run fetch_all wired to the rich progress display."""
86
+ tasks: dict[str, TaskID] = {
87
+ key: progress.add_task(label, total=None) for key, label in fetch.LEVELS
88
+ }
89
+
90
+ def on_progress(key: str, done: int, total: int) -> None:
91
+ progress.update(tasks[key], completed=done, total=total)
92
+
93
+ try:
94
+ return await fetch.fetch_all(
95
+ client,
96
+ meta,
97
+ start,
98
+ end,
99
+ timezone=timezone,
100
+ today=datetime.now(tz=UTC).date(),
101
+ on_progress=on_progress,
102
+ )
103
+ finally:
104
+ await client.aclose()
105
+
106
+
107
+ @app.command()
108
+ def analyze(
109
+ app_ids: Annotated[
110
+ list[int] | None,
111
+ typer.Option("--app", "-a", help="Adam ID to scope to; repeatable."),
112
+ ] = None,
113
+ period: Annotated[
114
+ Period, typer.Option("--period", "-p", help="Preset range ending yesterday.")
115
+ ] = Period.D30,
116
+ from_date: Annotated[
117
+ datetime | None,
118
+ typer.Option("--from", formats=["%Y-%m-%d"], help="Explicit start date."),
119
+ ] = None,
120
+ to_date: Annotated[
121
+ datetime | None,
122
+ typer.Option("--to", formats=["%Y-%m-%d"], help="Explicit end date."),
123
+ ] = None,
124
+ output: Annotated[
125
+ Path | None, typer.Option("--output", "-o", help="Output .xlsx path.")
126
+ ] = None,
127
+ timezone: Annotated[str, typer.Option("--timezone", help="Reporting timezone.")] = "UTC",
128
+ currency_format: Annotated[
129
+ str | None,
130
+ typer.Option("--currency-format", help="Excel number format for money."),
131
+ ] = None,
132
+ api_version: Annotated[
133
+ ApiVersion,
134
+ typer.Option("--api-version", help="Apple Ads API version to fetch with."),
135
+ ] = ApiVersion.V5,
136
+ ) -> None:
137
+ """Generate an Excel performance analysis workbook."""
138
+ today = datetime.now(tz=UTC).date()
139
+ try:
140
+ start, end = dates.resolve_range(
141
+ period.value,
142
+ from_date.date() if from_date else None,
143
+ to_date.date() if to_date else None,
144
+ today=today,
145
+ )
146
+ except ValueError as exc:
147
+ raise typer.BadParameter(str(exc)) from exc
148
+
149
+ client: AppleSearchAdsClient | V1FetchAdapter
150
+ try:
151
+ if api_version is ApiVersion.V1:
152
+ client = V1FetchAdapter(AppleAdsClient.from_env())
153
+ else:
154
+ client = AppleSearchAdsClient.from_env()
155
+ except ConfigurationError as exc:
156
+ raise _fail(str(exc)) from exc
157
+
158
+ try:
159
+ meta, currency = fetch.resolve_scope(client, app_ids)
160
+ with Progress(
161
+ TextColumn("[progress.description]{task.description}"),
162
+ BarColumn(),
163
+ TextColumn("{task.completed}/{task.total}"),
164
+ ) as progress:
165
+ result = asyncio.run(_fetch(client, meta, start, end, timezone, progress))
166
+ except fetch.ScopeError as exc:
167
+ raise _fail(str(exc)) from exc
168
+ except fetch.LevelFetchError as exc:
169
+ raise _fail(str(exc)) from exc
170
+ except AppleSearchAdsError as exc:
171
+ raise _fail(str(exc)) from exc
172
+ finally:
173
+ client.close()
174
+
175
+ for warning in result.warnings:
176
+ _err.print(f"[yellow]Warning:[/yellow] {warning}")
177
+
178
+ daily_frames = {key: metrics.normalize(lv.daily) for key, lv in result.levels.items()}
179
+ analysis = {
180
+ key: metrics.aggregate(daily_frames[key], metrics.LEVEL_KEYS[key]) for key in daily_frames
181
+ }
182
+ campaign_daily = daily_frames["campaigns"]
183
+ current_kpis = metrics.kpis(campaign_daily)
184
+ prior_kpis = (
185
+ metrics.kpis(metrics.normalize(result.prior_campaigns))
186
+ if not result.prior_campaigns.empty
187
+ else None
188
+ )
189
+
190
+ apps_in_scope = sorted(meta["adam_id"].unique().tolist())
191
+ if app_ids and len(apps_in_scope) == 1:
192
+ scope_label = meta["app_name"].iloc[0]
193
+ file_scope = str(apps_in_scope[0])
194
+ else:
195
+ scope_label = "All apps"
196
+ file_scope = "org"
197
+ day_count = (end - start).days + 1
198
+ fmt = currency_format or _currency_format(currency)
199
+
200
+ summary = SummaryData(
201
+ title=f"{scope_label} · Apple Search Ads performance",
202
+ period_label=f"{start} – {end} ({day_count} days)", # noqa: RUF001
203
+ timezone=timezone,
204
+ generated_at=datetime.now(tz=UTC),
205
+ kpis=current_kpis,
206
+ prior_kpis=prior_kpis,
207
+ daily=metrics.daily_series(campaign_daily),
208
+ per_app=metrics.per_app(campaign_daily) if len(apps_in_scope) > 1 else None,
209
+ top_keywords=metrics.top_keywords(analysis["keywords"]),
210
+ wasted=metrics.wasted_spend(analysis["keywords"]),
211
+ )
212
+
213
+ out_path = output or Path(f"asa-analysis-{file_scope}-{today:%Y-%m-%d}.xlsx")
214
+ notes = {key: lv.notes for key, lv in result.levels.items()}
215
+ try:
216
+ write_workbook(
217
+ out_path,
218
+ summary=summary,
219
+ analysis=analysis,
220
+ daily=daily_frames,
221
+ notes=notes,
222
+ currency_format=fmt,
223
+ )
224
+ except (OSError, XlsxWriterException) as exc:
225
+ raise _fail(f"Could not write {out_path}: {exc}") from exc
226
+
227
+ symbol = _currency_symbol(fmt)
228
+ spend = current_kpis["spend"]
229
+ installs = int(current_kpis["installs"])
230
+ cpa = current_kpis["cpa"]
231
+ cpa_text = f"{symbol}{cpa:,.2f} CPA" if cpa == cpa else "— CPA"
232
+ typer.echo(str(out_path))
233
+ typer.echo(
234
+ f"{symbol}{spend:,.0f} spend · {installs} installs · {cpa_text} over {day_count} days"
235
+ )
@@ -0,0 +1,95 @@
1
+ """Pure date arithmetic for the analyze command.
2
+
3
+ Stdlib-only so it can be imported and unit-tested without the ``cli``
4
+ extra installed.
5
+ """
6
+
7
+ from datetime import date, timedelta
8
+
9
+ PERIODS: dict[str, int] = {"30d": 30, "90d": 90, "365d": 365}
10
+ MAX_LOOKBACK_DAYS = 730
11
+ MAX_DAILY_WINDOW = 90
12
+ SEARCH_TERM_LOOKBACK = 90
13
+
14
+
15
+ def resolve_range(
16
+ period: str,
17
+ from_date: date | None,
18
+ to_date: date | None,
19
+ *,
20
+ today: date,
21
+ ) -> tuple[date, date]:
22
+ """Resolve CLI options into an inclusive (start, end) date range.
23
+
24
+ Args:
25
+ period: Preset key from :data:`PERIODS` (used when no explicit dates).
26
+ from_date: Explicit start date; overrides the preset.
27
+ to_date: Explicit end date; defaults to yesterday when only
28
+ ``from_date`` is given.
29
+ today: The current date (injected for testability).
30
+
31
+ Returns:
32
+ Inclusive ``(start, end)`` dates.
33
+
34
+ Raises:
35
+ ValueError: If the preset is unknown, the range is reversed, ends
36
+ in the future, or starts beyond the API lookback.
37
+ """
38
+ yesterday = today - timedelta(days=1)
39
+
40
+ if from_date is None and to_date is None:
41
+ if period not in PERIODS:
42
+ raise ValueError(f"Unknown period {period!r}; choose one of {sorted(PERIODS)}")
43
+ end = yesterday
44
+ start = end - timedelta(days=PERIODS[period] - 1)
45
+ else:
46
+ if from_date is None:
47
+ raise ValueError("--to requires --from")
48
+ start = from_date
49
+ end = to_date if to_date is not None else yesterday
50
+
51
+ if start > end:
52
+ raise ValueError(f"--from ({start}) must be before --to ({end})")
53
+ if end > yesterday:
54
+ raise ValueError(f"--to ({end}) must not be in the future; latest full day is {yesterday}")
55
+ if start < today - timedelta(days=MAX_LOOKBACK_DAYS):
56
+ raise ValueError(f"--from ({start}) is beyond the API lookback of {MAX_LOOKBACK_DAYS} days")
57
+
58
+ return start, end
59
+
60
+
61
+ def chunk_windows(
62
+ start: date, end: date, max_days: int = MAX_DAILY_WINDOW
63
+ ) -> list[tuple[date, date]]:
64
+ """Split an inclusive date range into contiguous windows of at most ``max_days``.
65
+
66
+ Args:
67
+ start: Range start (inclusive).
68
+ end: Range end (inclusive).
69
+ max_days: Maximum days per window (the API caps DAILY reports at 90).
70
+
71
+ Returns:
72
+ Ordered list of inclusive ``(start, end)`` windows tiling the range.
73
+ """
74
+ windows: list[tuple[date, date]] = []
75
+ cursor = start
76
+ while cursor <= end:
77
+ window_end = min(cursor + timedelta(days=max_days - 1), end)
78
+ windows.append((cursor, window_end))
79
+ cursor = window_end + timedelta(days=1)
80
+ return windows
81
+
82
+
83
+ def prior_window(start: date, end: date) -> tuple[date, date]:
84
+ """Return the equal-length window immediately preceding ``start``-``end``.
85
+
86
+ Args:
87
+ start: Current range start (inclusive).
88
+ end: Current range end (inclusive).
89
+
90
+ Returns:
91
+ Inclusive ``(start, end)`` of the preceding window.
92
+ """
93
+ length = (end - start).days
94
+ prior_end = start - timedelta(days=1)
95
+ return prior_end - timedelta(days=length), prior_end