asa-api-client 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.
Files changed (159) hide show
  1. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/PKG-INFO +1 -1
  2. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/__init__.py +1 -1
  3. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/analyze.py +11 -1
  4. asa_api_client-0.4.0/asa_api_client/cli/popularity.py +258 -0
  5. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/workbook.py +24 -0
  6. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/client.py +21 -2
  7. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/ad_groups.py +11 -58
  8. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/budget_orders.py +6 -39
  9. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/campaigns.py +12 -81
  10. asa_api_client-0.4.0/asa_api_client/v1/models/shared.py +99 -0
  11. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/creatives.py +55 -12
  12. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/pyproject.toml +1 -1
  13. asa_api_client-0.4.0/tests/unit/cli/test_popularity.py +434 -0
  14. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_workbook.py +56 -1
  15. asa_api_client-0.4.0/tests/unit/test_deprecation.py +44 -0
  16. asa_api_client-0.4.0/tests/unit/test_version.py +17 -0
  17. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_creatives_assets.py +26 -0
  18. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/uv.lock +1 -1
  19. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  20. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  21. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  22. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/dependabot.yml +0 -0
  23. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/workflows/ci.yml +0 -0
  24. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/workflows/docs.yml +0 -0
  25. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.github/workflows/publish.yml +0 -0
  26. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.gitignore +0 -0
  27. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/.pre-commit-config.yaml +0 -0
  28. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/LICENSE +0 -0
  29. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/README.md +0 -0
  30. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/auth.py +0 -0
  31. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/__init__.py +0 -0
  32. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/dates.py +0 -0
  33. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/fetch.py +0 -0
  34. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/metrics.py +0 -0
  35. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/v1_adapter.py +0 -0
  36. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/cli/v1_smoke.py +0 -0
  37. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/exceptions.py +0 -0
  38. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/logging.py +0 -0
  39. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/__init__.py +0 -0
  40. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/acls.py +0 -0
  41. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/ad_groups.py +0 -0
  42. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/ads.py +0 -0
  43. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/apps.py +0 -0
  44. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/base.py +0 -0
  45. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/budget_orders.py +0 -0
  46. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/campaigns.py +0 -0
  47. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/countries.py +0 -0
  48. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/geo.py +0 -0
  49. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/keywords.py +0 -0
  50. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/product_pages.py +0 -0
  51. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/models/reports.py +0 -0
  52. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/__init__.py +0 -0
  53. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/acls.py +0 -0
  54. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/ad_groups.py +0 -0
  55. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/ads.py +0 -0
  56. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/apps.py +0 -0
  57. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/base.py +0 -0
  58. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/budget_orders.py +0 -0
  59. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/campaigns.py +0 -0
  60. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/countries.py +0 -0
  61. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/custom_reports.py +0 -0
  62. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/geo.py +0 -0
  63. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/keywords.py +0 -0
  64. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/product_pages.py +0 -0
  65. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/resources/reports.py +0 -0
  66. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/settings.py +0 -0
  67. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/utils/__init__.py +0 -0
  68. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/__init__.py +0 -0
  69. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/client.py +0 -0
  70. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/__init__.py +0 -0
  71. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/ad_accounts.py +0 -0
  72. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/ads.py +0 -0
  73. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/apps.py +0 -0
  74. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/base.py +0 -0
  75. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/brands.py +0 -0
  76. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/bulk.py +0 -0
  77. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/change_history.py +0 -0
  78. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/creatives.py +0 -0
  79. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/geo.py +0 -0
  80. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/insights.py +0 -0
  81. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/keywords.py +0 -0
  82. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/product_pages.py +0 -0
  83. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/recommendations.py +0 -0
  84. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/reports.py +0 -0
  85. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/models/suggestions.py +0 -0
  86. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/query.py +0 -0
  87. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/__init__.py +0 -0
  88. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/ad_accounts.py +0 -0
  89. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/ad_groups.py +0 -0
  90. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/ads.py +0 -0
  91. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/apps.py +0 -0
  92. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/base.py +0 -0
  93. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/brands.py +0 -0
  94. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/budget_orders.py +0 -0
  95. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/bulk.py +0 -0
  96. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/campaigns.py +0 -0
  97. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/change_history.py +0 -0
  98. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/geo.py +0 -0
  99. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/insights.py +0 -0
  100. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/keywords.py +0 -0
  101. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/product_pages.py +0 -0
  102. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/recommendations.py +0 -0
  103. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/reports.py +0 -0
  104. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/asa_api_client/v1/resources/suggestions.py +0 -0
  105. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/CNAME +0 -0
  106. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/api/client.md +0 -0
  107. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/api/exceptions.md +0 -0
  108. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/api/models.md +0 -0
  109. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/getting-started/authentication.md +0 -0
  110. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/getting-started/installation.md +0 -0
  111. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/getting-started/quickstart.md +0 -0
  112. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/ad-groups.md +0 -0
  113. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/async.md +0 -0
  114. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/campaigns.md +0 -0
  115. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/cli.md +0 -0
  116. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/keywords.md +0 -0
  117. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/platform-api-v1.md +0 -0
  118. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/guide/reports.md +0 -0
  119. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/index.md +0 -0
  120. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/superpowers/plans/2026-08-05-analyze-cli.md +0 -0
  121. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/superpowers/plans/2026-08-16-apple-ads-platform-api-v1.md +0 -0
  122. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/superpowers/specs/2026-08-05-analyze-cli-design.md +0 -0
  123. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/docs/superpowers/specs/2026-08-16-apple-ads-platform-api-v1-design.md +0 -0
  124. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/mkdocs.yml +0 -0
  125. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/__init__.py +0 -0
  126. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/__init__.py +0 -0
  127. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/__init__.py +0 -0
  128. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/conftest.py +0 -0
  129. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_cli.py +0 -0
  130. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_dates.py +0 -0
  131. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_entry.py +0 -0
  132. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_fetch.py +0 -0
  133. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_metrics.py +0 -0
  134. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_v1_adapter.py +0 -0
  135. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/cli/test_v1_smoke.py +0 -0
  136. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/test_models.py +0 -0
  137. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/__init__.py +0 -0
  138. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/conftest.py +0 -0
  139. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_ad_accounts.py +0 -0
  140. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_ad_groups.py +0 -0
  141. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_ads.py +0 -0
  142. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_apps.py +0 -0
  143. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_brands.py +0 -0
  144. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_budget_orders.py +0 -0
  145. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_bulk.py +0 -0
  146. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_campaigns.py +0 -0
  147. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_change_history.py +0 -0
  148. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_client.py +0 -0
  149. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_exceptions.py +0 -0
  150. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_geo.py +0 -0
  151. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_insights.py +0 -0
  152. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_keywords.py +0 -0
  153. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_models_base.py +0 -0
  154. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_product_pages.py +0 -0
  155. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_query.py +0 -0
  156. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_recommendations.py +0 -0
  157. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_reports.py +0 -0
  158. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_resource_base.py +0 -0
  159. {asa_api_client-0.3.0 → asa_api_client-0.4.0}/tests/unit/v1/test_suggestions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asa-api-client
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -51,4 +51,4 @@ __all__ = [
51
51
  "configure_logging",
52
52
  ]
53
53
 
54
- __version__ = "0.3.0"
54
+ __version__ = "0.4.0"
@@ -12,7 +12,7 @@ from rich.console import Console
12
12
  from rich.progress import BarColumn, Progress, TaskID, TextColumn
13
13
  from xlsxwriter.exceptions import XlsxWriterException
14
14
 
15
- from asa_api_client.cli import dates, fetch, metrics, v1_smoke
15
+ from asa_api_client.cli import dates, fetch, metrics, popularity, v1_smoke
16
16
  from asa_api_client.cli.v1_adapter import V1FetchAdapter
17
17
  from asa_api_client.cli.workbook import SummaryData, write_workbook
18
18
  from asa_api_client.client import AppleSearchAdsClient
@@ -210,6 +210,14 @@ def analyze(
210
210
  wasted=metrics.wasted_spend(analysis["keywords"]),
211
211
  )
212
212
 
213
+ # The popularity sheet is enrichment via the v1 insights API; it
214
+ # must never break (or fail) an otherwise successful analyze run.
215
+ try:
216
+ popularity_frame, popularity_notes = popularity.build_popularity(result)
217
+ except Exception as exc: # nothing from this sheet may abort the workbook
218
+ popularity_frame, popularity_notes = pd.DataFrame(), []
219
+ _err.print(f"[yellow]Warning:[/yellow] Search Popularity sheet skipped: {exc}")
220
+
213
221
  out_path = output or Path(f"asa-analysis-{file_scope}-{today:%Y-%m-%d}.xlsx")
214
222
  notes = {key: lv.notes for key, lv in result.levels.items()}
215
223
  try:
@@ -220,6 +228,8 @@ def analyze(
220
228
  daily=daily_frames,
221
229
  notes=notes,
222
230
  currency_format=fmt,
231
+ popularity=popularity_frame,
232
+ popularity_notes=popularity_notes,
223
233
  )
224
234
  except (OSError, XlsxWriterException) as exc:
225
235
  raise _fail(f"Could not write {out_path}: {exc}") from exc
@@ -0,0 +1,258 @@
1
+ """Search Popularity enrichment for the analyze command.
2
+
3
+ Matches the organization's keywords and search terms against Apple's
4
+ search-term-popularity insights (Apple Ads Platform API v1) to show how
5
+ much App Store search volume each term actually carries.
6
+
7
+ Live-API behavior this module works around (documented in
8
+ :mod:`asa_api_client.v1.resources.insights`):
9
+
10
+ - Any ``filters`` in the request make the API return zero rows, so the
11
+ query is always unfiltered and matching happens client-side.
12
+ - Weekly windows must start on a Sunday, so the query targets the most
13
+ recent complete Sun-Sat week.
14
+ - Data is published with roughly a week's lag, so an empty latest week
15
+ falls back exactly one week and retries once.
16
+
17
+ Failures never propagate: any API or configuration problem degrades to
18
+ an empty frame plus an explanatory note.
19
+ """
20
+
21
+ import os
22
+ from collections.abc import Callable
23
+ from datetime import UTC, date, datetime, timedelta
24
+
25
+ import pandas as pd
26
+
27
+ from asa_api_client.cli import fetch
28
+ from asa_api_client.exceptions import AppleSearchAdsError
29
+ from asa_api_client.v1.client import AppleAdsClient
30
+ from asa_api_client.v1.models.insights import (
31
+ SearchTermPopularityGranularity,
32
+ SearchTermPopularityQueryRequest,
33
+ SearchTermPopularityRow,
34
+ SearchTermPopularityTimeRange,
35
+ )
36
+
37
+ POPULARITY_COLUMNS: list[str] = [
38
+ "search_term",
39
+ "source",
40
+ "country_or_region",
41
+ "genre",
42
+ "rank_in_genre",
43
+ "search_popularity_1_to_100",
44
+ "search_popularity_1_to_5",
45
+ ]
46
+
47
+ _FIELDS = (
48
+ "rankInGenre",
49
+ "searchPopularityInGenre",
50
+ "searchPopularity1to100",
51
+ "searchPopularity1to5",
52
+ )
53
+
54
+ _NO_MATCH_NOTE = (
55
+ "No account keywords or search terms meet Apple's ≥500 searches/week popularity floor."
56
+ )
57
+
58
+
59
+ class _SkipPopularity(Exception):
60
+ """The sheet cannot be built; the message is the explanatory note."""
61
+
62
+
63
+ def latest_complete_week(today: date) -> tuple[date, date]:
64
+ """Find the most recent complete Sun-Sat week ending before today.
65
+
66
+ Args:
67
+ today: The current date.
68
+
69
+ Returns:
70
+ The inclusive ``(sunday, saturday)`` pair of the latest Sun-Sat
71
+ week whose Saturday is at least one day in the past.
72
+ """
73
+ reference = today - timedelta(days=1)
74
+ saturday = reference - timedelta(days=(reference.weekday() - 5) % 7)
75
+ return saturday - timedelta(days=6), saturday
76
+
77
+
78
+ def _level_terms(result: fetch.FetchResult, level: str, column: str) -> set[str]:
79
+ """Collect lowercased distinct term texts from one level frame.
80
+
81
+ Args:
82
+ result: The fetched analyze data.
83
+ level: The level key (e.g. ``"keywords"``).
84
+ column: The term column on that level's daily frame.
85
+
86
+ Returns:
87
+ The distinct non-empty terms, lowercased. Empty when the level,
88
+ its frame, or the column is missing.
89
+ """
90
+ data = result.levels.get(level)
91
+ if data is None or data.daily.empty or column not in data.daily.columns:
92
+ return set()
93
+ return {str(value).lower() for value in data.daily[column].dropna() if str(value)}
94
+
95
+
96
+ def _resolve_account(client: AppleAdsClient) -> str | None:
97
+ """Ensure the client has an ad account, auto-selecting from ACLs.
98
+
99
+ Prefers the ACL ad account whose ID equals ``ASA_ORG_ID`` (orgs and
100
+ their primary ad accounts commonly share an ID); otherwise a single
101
+ accessible account is used.
102
+
103
+ Args:
104
+ client: The v1 API client.
105
+
106
+ Returns:
107
+ A note describing the auto-selection, or None when the client
108
+ already had an account configured.
109
+
110
+ Raises:
111
+ _SkipPopularity: When no account can be selected unambiguously.
112
+ """
113
+ if client.ad_account_id is not None:
114
+ return None
115
+ accounts = [
116
+ acl.ad_account
117
+ for acl in client.acls.list()
118
+ if acl.ad_account is not None and acl.ad_account.id is not None
119
+ ]
120
+ org_id = os.environ.get("ASA_ORG_ID")
121
+ selected = next((a for a in accounts if str(a.id) == org_id), None) if org_id else None
122
+ if selected is None and len(accounts) == 1:
123
+ selected = accounts[0]
124
+ if selected is None:
125
+ raise _SkipPopularity(
126
+ "Search Popularity skipped: ASA_AD_ACCOUNT_ID is not set and the "
127
+ f"ad account could not be auto-selected from {len(accounts)} ACL entries."
128
+ )
129
+ client.ad_account_id = str(selected.id)
130
+ return f"Ad account {selected.id} ({selected.name}) auto-selected for popularity data."
131
+
132
+
133
+ def _week_rows(
134
+ client: AppleAdsClient, sunday: date, saturday: date
135
+ ) -> list[SearchTermPopularityRow]:
136
+ """Query one Sun-Sat week of popularity data, unfiltered.
137
+
138
+ Args:
139
+ client: The v1 API client.
140
+ sunday: The week's start (must be a Sunday).
141
+ saturday: The week's end.
142
+
143
+ Returns:
144
+ Every popularity row for the week across all storefronts.
145
+ """
146
+ request = SearchTermPopularityQueryRequest(
147
+ fields=list(_FIELDS),
148
+ time_range=SearchTermPopularityTimeRange(
149
+ start=sunday,
150
+ end=saturday,
151
+ granularity=SearchTermPopularityGranularity.WEEKLY_SUN_SAT,
152
+ ),
153
+ )
154
+ return list(client.insights.query_search_term_popularity(request))
155
+
156
+
157
+ def _match(
158
+ rows: list[SearchTermPopularityRow], keyword_terms: set[str], search_terms: set[str]
159
+ ) -> pd.DataFrame:
160
+ """Keep the popularity rows whose term the org bids on or matched.
161
+
162
+ Args:
163
+ rows: All popularity rows for the queried week.
164
+ keyword_terms: Lowercased keyword texts from the keywords level.
165
+ search_terms: Lowercased texts from the search-terms level.
166
+
167
+ Returns:
168
+ The matched rows with :data:`POPULARITY_COLUMNS`, sorted by
169
+ overall popularity (desc) then in-genre rank (asc).
170
+ """
171
+ records: list[dict[str, object]] = []
172
+ for row in rows:
173
+ term = (row.search_term or "").lower()
174
+ in_keywords = term in keyword_terms
175
+ in_search_terms = term in search_terms
176
+ if not term or not (in_keywords or in_search_terms):
177
+ continue
178
+ if in_keywords and in_search_terms:
179
+ source = "Both"
180
+ else:
181
+ source = "Keywords" if in_keywords else "Search Terms"
182
+ records.append(
183
+ {
184
+ "search_term": row.search_term,
185
+ "source": source,
186
+ "country_or_region": row.country_or_region,
187
+ "genre": row.genre,
188
+ "rank_in_genre": row.rank_in_genre,
189
+ "search_popularity_1_to_100": row.search_popularity_1_to_100,
190
+ "search_popularity_1_to_5": row.search_popularity_1_to_5,
191
+ }
192
+ )
193
+ frame = pd.DataFrame(records, columns=POPULARITY_COLUMNS)
194
+ if frame.empty:
195
+ return frame
196
+ return frame.sort_values(
197
+ by=["search_popularity_1_to_100", "rank_in_genre"],
198
+ ascending=[False, True],
199
+ na_position="last",
200
+ kind="mergesort",
201
+ ).reset_index(drop=True)
202
+
203
+
204
+ def build_popularity(
205
+ result: fetch.FetchResult,
206
+ *,
207
+ client_factory: Callable[[], AppleAdsClient] | None = None,
208
+ today: date | None = None,
209
+ ) -> tuple[pd.DataFrame, list[str]]:
210
+ """Build the Search Popularity frame for an analyze run.
211
+
212
+ Collects the org's keyword and search-term texts from ``result``,
213
+ queries one week of search-term popularity via the v1 API (falling
214
+ back one week when the latest week has no data yet), and keeps the
215
+ rows matching the org's terms across all storefronts.
216
+
217
+ Args:
218
+ result: The fetched analyze data (term sources).
219
+ client_factory: Zero-argument v1 client factory; defaults to
220
+ :meth:`AppleAdsClient.from_env`. The client is closed before
221
+ returning.
222
+ today: The current date; defaults to today (UTC).
223
+
224
+ Returns:
225
+ Tuple of (popularity frame with :data:`POPULARITY_COLUMNS`,
226
+ notes for the sheet). On any API or configuration failure the
227
+ frame is empty and the notes explain why — this function never
228
+ raises for those errors.
229
+ """
230
+ keyword_terms = _level_terms(result, "keywords", "keyword")
231
+ search_terms = _level_terms(result, "search_terms", "search_term_text")
232
+ if not (keyword_terms or search_terms):
233
+ return pd.DataFrame(columns=POPULARITY_COLUMNS), [
234
+ "No keywords or search terms in scope; popularity lookup skipped."
235
+ ]
236
+
237
+ factory = client_factory or AppleAdsClient.from_env
238
+ try:
239
+ with factory() as client:
240
+ account_note = _resolve_account(client)
241
+ sunday, saturday = latest_complete_week(today or datetime.now(tz=UTC).date())
242
+ rows = _week_rows(client, sunday, saturday)
243
+ if not rows:
244
+ sunday -= timedelta(days=7)
245
+ saturday -= timedelta(days=7)
246
+ rows = _week_rows(client, sunday, saturday)
247
+ except _SkipPopularity as exc:
248
+ return pd.DataFrame(columns=POPULARITY_COLUMNS), [str(exc)]
249
+ except AppleSearchAdsError as exc:
250
+ return pd.DataFrame(columns=POPULARITY_COLUMNS), [f"Search Popularity unavailable: {exc}"]
251
+
252
+ notes = [f"Popularity week: {sunday} – {saturday}"] # noqa: RUF001
253
+ if account_note is not None:
254
+ notes.append(account_note)
255
+ frame = _match(rows, keyword_terms, search_terms)
256
+ if frame.empty:
257
+ notes.append(_NO_MATCH_NOTE)
258
+ return frame, notes
@@ -90,6 +90,17 @@ ANALYSIS_COLUMNS: dict[str, list[tuple[str, str, str]]] = {
90
90
  ],
91
91
  }
92
92
 
93
+ POPULARITY_SHEET = "Search Popularity"
94
+ _POPULARITY_SPEC: list[tuple[str, str, str]] = [
95
+ ("search_term", "Search Term", "text"),
96
+ ("source", "Source", "text"),
97
+ ("country_or_region", "Country/Region", "text"),
98
+ ("genre", "Genre", "text"),
99
+ ("rank_in_genre", "Rank in Genre", "int"),
100
+ ("search_popularity_1_to_100", "Popularity (1-100)", "int"),
101
+ ("search_popularity_1_to_5", "Popularity (1-5)", "int"),
102
+ ]
103
+
93
104
  _COLUMN_WIDTHS = {"text": 28, "id": 12, "int": 12, "currency": 12, "percent": 9}
94
105
  _TAB_GREY = "#9CA3AF"
95
106
 
@@ -406,6 +417,8 @@ def write_workbook(
406
417
  daily: dict[str, pd.DataFrame],
407
418
  notes: dict[str, list[str]],
408
419
  currency_format: str = "$#,##0.00",
420
+ popularity: pd.DataFrame | None = None,
421
+ popularity_notes: list[str] | None = None,
409
422
  ) -> None:
410
423
  """Write the full analysis workbook to ``path``.
411
424
 
@@ -416,6 +429,10 @@ def write_workbook(
416
429
  daily: Normalized daily frames keyed by level key.
417
430
  notes: Per-level notes rendered above the daily tables.
418
431
  currency_format: Excel number format for money cells.
432
+ popularity: Optional Search Popularity frame; the sheet is
433
+ written when the frame has rows or ``popularity_notes`` has
434
+ entries, and omitted entirely when ``popularity`` is None.
435
+ popularity_notes: Notes rendered above the popularity table.
419
436
  """
420
437
  book = xlsxwriter.Workbook(str(path), {"nan_inf_to_errors": True})
421
438
  try:
@@ -431,6 +448,13 @@ def write_workbook(
431
448
  fmts,
432
449
  cpa_benchmark,
433
450
  )
451
+ if popularity is not None and (not popularity.empty or popularity_notes):
452
+ ws = book.add_worksheet(POPULARITY_SHEET)
453
+ start = 0
454
+ for note in popularity_notes or []:
455
+ ws.write_string(start, 0, note, fmts["note"])
456
+ start += 1
457
+ _write_table(ws, popularity, _POPULARITY_SPEC, fmts, start_row=start)
434
458
  for key, label in LEVELS:
435
459
  ws = book.add_worksheet(f"Daily · {label}")
436
460
  ws.set_tab_color(_TAB_GREY)
@@ -4,6 +4,7 @@ This module provides the AppleSearchAdsClient class, which is the
4
4
  primary interface for interacting with the Apple Search Ads API.
5
5
  """
6
6
 
7
+ import warnings
7
8
  from pathlib import Path
8
9
  from types import TracebackType
9
10
  from typing import Self
@@ -29,9 +30,15 @@ DEFAULT_BASE_URL = "https://api.searchads.apple.com/api/v5"
29
30
 
30
31
 
31
32
  class AppleSearchAdsClient:
32
- """Client for interacting with the Apple Search Ads API.
33
+ """Client for the legacy Apple Search Ads Campaign Management API v5.
33
34
 
34
- This is the main entry point for the library. It provides access
35
+ .. deprecated:: 0.4.0
36
+ Apple sunsets the Campaign Management API v5 on **2027-01-26**.
37
+ Migrate to :class:`~asa_api_client.AppleAdsClient` (Platform API
38
+ v1) — it uses the same credentials. Constructing this client
39
+ emits a ``DeprecationWarning``.
40
+
41
+ This is the v5 entry point for the library. It provides access
35
42
  to all API resources through a structured, resource-based interface.
36
43
 
37
44
  The client supports both synchronous and asynchronous operations.
@@ -125,7 +132,19 @@ class AppleSearchAdsClient:
125
132
 
126
133
  Raises:
127
134
  ConfigurationError: If credentials are invalid or missing.
135
+
136
+ Warns:
137
+ DeprecationWarning: The Campaign Management API v5 sunsets
138
+ on 2027-01-26; migrate to ``AppleAdsClient``.
128
139
  """
140
+ warnings.warn(
141
+ "AppleSearchAdsClient targets the Campaign Management API v5, "
142
+ "which Apple sunsets on 2027-01-26. Migrate to AppleAdsClient "
143
+ "(Apple Ads Platform API v1) — same credentials; see "
144
+ "https://asa-api-client.peth.au/guide/platform-api-v1/",
145
+ DeprecationWarning,
146
+ stacklevel=2,
147
+ )
129
148
  self.org_id = org_id
130
149
  self._base_url = base_url.rstrip("/")
131
150
  self._timeout = timeout
@@ -14,7 +14,17 @@ from enum import StrEnum
14
14
 
15
15
  from pydantic import Field
16
16
 
17
- from asa_api_client.v1.models.base import Money, V1Model
17
+ from asa_api_client.v1.models.base import V1Model
18
+ from asa_api_client.v1.models.shared import (
19
+ BidStrategy,
20
+ TargetingData,
21
+ )
22
+ from asa_api_client.v1.models.shared import (
23
+ BidStrategyGoal as BidStrategyGoal,
24
+ )
25
+ from asa_api_client.v1.models.shared import (
26
+ BidStrategyType as BidStrategyType,
27
+ )
18
28
 
19
29
 
20
30
  class PricingModel(StrEnum):
@@ -90,44 +100,6 @@ class AdGroupSystemLimitedStatusReason(StrEnum):
90
100
  ADS_LIMITED = "ADS_LIMITED"
91
101
 
92
102
 
93
- class BidStrategyType(StrEnum):
94
- """Bidding strategy applied to the ad group's auctions."""
95
-
96
- MANUAL_CPT = "MANUAL_CPT"
97
- MANUAL_CPM = "MANUAL_CPM"
98
- MAX_CONVERSIONS = "MAX_CONVERSIONS"
99
- MAX_ENGAGEMENTS = "MAX_ENGAGEMENTS"
100
-
101
-
102
- class BidStrategyGoal(StrEnum):
103
- """Optimization goal paired with a bid strategy type.
104
-
105
- Documented pairings: ``MANUAL_CPT`` ↔ ``TAP``, ``MANUAL_CPM`` ↔
106
- ``IMPRESSION``, ``MAX_CONVERSIONS`` ↔ ``INSTALL``,
107
- ``MAX_ENGAGEMENTS`` ↔ ``TAP``.
108
- """
109
-
110
- IMPRESSION = "IMPRESSION"
111
- INSTALL = "INSTALL"
112
- TAP = "TAP"
113
-
114
-
115
- class TargetingData(V1Model):
116
- """Include/exclude value lists for one targeting dimension.
117
-
118
- All values are strings, even numeric IDs and ages (e.g. ``"18"``,
119
- ``"123456789"``). Only ``appCategory`` and ``appDownloader``
120
- support ``exclude``; other dimensions are include-only.
121
-
122
- Attributes:
123
- include: Values to restrict delivery to.
124
- exclude: Values to block delivery to.
125
- """
126
-
127
- include: list[str] | None = None
128
- exclude: list[str] | None = None
129
-
130
-
131
103
  class AdGroupTargeting(V1Model):
132
104
  """Targeting dimensions for an ad group.
133
105
 
@@ -168,25 +140,6 @@ class AdGroupTargeting(V1Model):
168
140
  location_group: TargetingData | None = Field(default=None, alias="locationGroup")
169
141
 
170
142
 
171
- class BidStrategy(V1Model):
172
- """Bid strategy for an ad group.
173
-
174
- ``bid_strategy_type`` and ``bid_strategy_goal`` must always be
175
- sent together as a documented pairing; omitting one or mismatching
176
- them returns an error.
177
-
178
- Attributes:
179
- bid_strategy_type: The bidding strategy type.
180
- bid_strategy_goal: The optimization goal matching the type.
181
- bid: For ``MANUAL_CPT`` the max amount paid per tap; for
182
- automated strategies an optional per-auction ceiling.
183
- """
184
-
185
- bid_strategy_type: BidStrategyType | None = Field(default=None, alias="bidStrategyType")
186
- bid_strategy_goal: BidStrategyGoal | None = Field(default=None, alias="bidStrategyGoal")
187
- bid: Money | None = None
188
-
189
-
190
143
  class AdGroup(V1Model):
191
144
  """An ad group as returned by the API.
192
145
 
@@ -13,6 +13,12 @@ from typing import Annotated
13
13
  from pydantic import Field, field_serializer
14
14
 
15
15
  from asa_api_client.v1.models.base import Money, V1Model
16
+ from asa_api_client.v1.models.shared import (
17
+ InvoiceDetail,
18
+ )
19
+ from asa_api_client.v1.models.shared import (
20
+ SharedBudgetAssignment as SharedBudgetAssignment,
21
+ )
16
22
 
17
23
  # The API requires yyyy-MM-dd'T'HH:mm:ss.SSS timestamps in UTC with no
18
24
  # timezone suffix.
@@ -91,32 +97,6 @@ class BudgetSystemStatusReason(StrEnum):
91
97
  SCHEDULE_PENDING = "SCHEDULE_PENDING"
92
98
 
93
99
 
94
- class InvoiceDetail(V1Model):
95
- """Billing details for Line of Credit (LOC) accounts.
96
-
97
- Embedded in budget order (and campaign) objects. On budget order
98
- creation the API requires ``name``, ``primary_buyer_name``,
99
- ``primary_buyer_email``, and ``billing_email``; all fields are
100
- modeled optional because responses may omit any of them.
101
-
102
- Attributes:
103
- name: Billing contact name (required on budget-order create).
104
- client_name: Identifies the advertiser or product. Nullable.
105
- primary_buyer_name: Primary buyer's name (required on create).
106
- primary_buyer_email: Primary buyer's email address (required
107
- on create).
108
- order_number: Purchase order (PO) number. Nullable.
109
- billing_email: Billing email address (required on create).
110
- """
111
-
112
- name: str | None = None
113
- client_name: str | None = Field(default=None, alias="clientName")
114
- primary_buyer_name: str | None = Field(default=None, alias="primaryBuyerName")
115
- primary_buyer_email: str | None = Field(default=None, alias="primaryBuyerEmail")
116
- order_number: str | None = Field(default=None, alias="orderNumber")
117
- billing_email: str | None = Field(default=None, alias="billingEmail")
118
-
119
-
120
100
  class InvoiceDetailUpdate(V1Model):
121
101
  """Partial update of a budget order's invoice details.
122
102
 
@@ -244,19 +224,6 @@ class SharedBudgetUpdate(V1Model):
244
224
  return _format_timestamp(value)
245
225
 
246
226
 
247
- class SharedBudgetAssignment(V1Model):
248
- """Read-only link between a campaign and a budget order.
249
-
250
- Returned only inside a Campaign's ``sharedBudgets`` array; NOT
251
- independently addressable via any endpoint.
252
-
253
- Attributes:
254
- budget_id: The budget order the campaign is assigned to.
255
- """
256
-
257
- budget_id: int | None = Field(default=None, alias="budgetId")
258
-
259
-
260
227
  class SharedBudgetAssignmentCreate(V1Model):
261
228
  """Assignment entry embedded in a CampaignCreate ``sharedBudgets`` array.
262
229
 
@@ -13,6 +13,18 @@ from enum import StrEnum
13
13
  from pydantic import Field, field_serializer
14
14
 
15
15
  from asa_api_client.v1.models.base import Money, V1Model
16
+ from asa_api_client.v1.models.shared import (
17
+ BidStrategy,
18
+ InvoiceDetail,
19
+ SharedBudgetAssignment,
20
+ TargetingData,
21
+ )
22
+ from asa_api_client.v1.models.shared import (
23
+ BidStrategyGoal as BidStrategyGoal,
24
+ )
25
+ from asa_api_client.v1.models.shared import (
26
+ BidStrategyType as BidStrategyType,
27
+ )
16
28
 
17
29
 
18
30
  class BillingEvent(StrEnum):
@@ -161,23 +173,6 @@ class SupplyPlacement(StrEnum):
161
173
  MAPS_SEARCH_HOME = "MAPS_SEARCH_HOME"
162
174
 
163
175
 
164
- class BidStrategyType(StrEnum):
165
- """How bids are set in auctions."""
166
-
167
- MANUAL_CPT = "MANUAL_CPT"
168
- MANUAL_CPM = "MANUAL_CPM"
169
- MAX_CONVERSIONS = "MAX_CONVERSIONS"
170
- MAX_ENGAGEMENTS = "MAX_ENGAGEMENTS"
171
-
172
-
173
- class BidStrategyGoal(StrEnum):
174
- """The outcome a bid strategy optimizes for."""
175
-
176
- IMPRESSION = "IMPRESSION"
177
- INSTALL = "INSTALL"
178
- TAP = "TAP"
179
-
180
-
181
176
  class RegulationType(StrEnum):
182
177
  """Regulatory disclosure types."""
183
178
 
@@ -198,21 +193,6 @@ class RegulationResponseValue(StrEnum):
198
193
  NOT_ANSWERED = "NOT_ANSWERED"
199
194
 
200
195
 
201
- class TargetingData(V1Model):
202
- """A targeting dimension's include/exclude value sets.
203
-
204
- Campaign-level targeting is include-only: ``exclude`` is unsupported
205
- for every campaign targeting dimension (unlike ad-group targeting).
206
-
207
- Attributes:
208
- include: Values to target.
209
- exclude: Values to exclude (ad-group targeting only).
210
- """
211
-
212
- include: list[str] | None = None
213
- exclude: list[str] | None = None
214
-
215
-
216
196
  class CampaignTargeting(V1Model):
217
197
  """Campaign-level targeting: supply source, placements, markets.
218
198
 
@@ -237,55 +217,6 @@ class DailyBudget(V1Model):
237
217
  value: Money | None = None
238
218
 
239
219
 
240
- class BidStrategy(V1Model):
241
- """How a campaign competes in auctions.
242
-
243
- ``bid_strategy_type`` and ``bid_strategy_goal`` must both be
244
- supplied together and match an allowed pairing on create and update.
245
-
246
- Attributes:
247
- bid_strategy_type: The bidding type (manual or automated).
248
- bid_strategy_goal: The outcome the strategy optimizes for.
249
- bid: The bid amount for manual strategies.
250
- """
251
-
252
- bid_strategy_type: BidStrategyType | None = Field(default=None, alias="bidStrategyType")
253
- bid_strategy_goal: BidStrategyGoal | None = Field(default=None, alias="bidStrategyGoal")
254
- bid: Money | None = None
255
-
256
-
257
- class SharedBudgetAssignment(V1Model):
258
- """One budget-order assignment in a campaign's ``sharedBudgets``.
259
-
260
- Attributes:
261
- budget_id: The assigned budget order's ID.
262
- """
263
-
264
- budget_id: int | None = Field(default=None, alias="budgetId")
265
-
266
-
267
- class InvoiceDetail(V1Model):
268
- """Invoice billing details for Line of Credit (LOC) accounts.
269
-
270
- ``primary_buyer_name``, ``primary_buyer_email``, and
271
- ``billing_email`` are required by the API when creating a campaign
272
- on an LOC account.
273
-
274
- Attributes:
275
- client_name: Identifies the advertiser or product.
276
- primary_buyer_name: The primary buyer's name.
277
- primary_buyer_email: The primary buyer's email address.
278
- order_number: Purchase order number.
279
- billing_email: The billing email address.
280
- """
281
-
282
- client_name: str | None = Field(default=None, alias="clientName")
283
- primary_buyer_name: str | None = Field(default=None, alias="primaryBuyerName")
284
- primary_buyer_email: str | None = Field(default=None, alias="primaryBuyerEmail")
285
- order_number: str | None = Field(default=None, alias="orderNumber")
286
- billing_email: str | None = Field(default=None, alias="billingEmail")
287
-
288
-
289
220
  class RegulationResponse(V1Model):
290
221
  """A regulatory consent acknowledgment on a campaign.
291
222