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.
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/ci.yml +5 -2
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/docs.yml +5 -5
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/workflows/publish.yml +1 -1
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/PKG-INFO +49 -7
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/README.md +37 -5
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/__init__.py +5 -1
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/auth.py +8 -4
- asa_api_client-0.3.0/asa_api_client/cli/__init__.py +20 -0
- asa_api_client-0.3.0/asa_api_client/cli/analyze.py +235 -0
- asa_api_client-0.3.0/asa_api_client/cli/dates.py +95 -0
- asa_api_client-0.3.0/asa_api_client/cli/fetch.py +464 -0
- asa_api_client-0.3.0/asa_api_client/cli/metrics.py +197 -0
- asa_api_client-0.3.0/asa_api_client/cli/v1_adapter.py +625 -0
- asa_api_client-0.3.0/asa_api_client/cli/v1_smoke.py +396 -0
- asa_api_client-0.3.0/asa_api_client/cli/workbook.py +444 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/exceptions.py +33 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/apps.py +1 -3
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/campaigns.py +1 -3
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/reports.py +1 -3
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/base.py +2 -2
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/product_pages.py +1 -3
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/reports.py +49 -7
- asa_api_client-0.3.0/asa_api_client/v1/__init__.py +16 -0
- asa_api_client-0.3.0/asa_api_client/v1/client.py +521 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/__init__.py +5 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/ad_accounts.py +352 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/ad_groups.py +301 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/ads.py +204 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/apps.py +193 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/base.py +149 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/brands.py +395 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/budget_orders.py +283 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/bulk.py +171 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/campaigns.py +501 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/change_history.py +245 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/creatives.py +426 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/geo.py +164 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/insights.py +300 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/keywords.py +195 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/product_pages.py +140 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/recommendations.py +642 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/reports.py +1854 -0
- asa_api_client-0.3.0/asa_api_client/v1/models/suggestions.py +98 -0
- asa_api_client-0.3.0/asa_api_client/v1/query.py +177 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/__init__.py +1 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/ad_accounts.py +217 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/ad_groups.py +61 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/ads.py +46 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/apps.py +325 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/base.py +803 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/brands.py +139 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/budget_orders.py +71 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/bulk.py +322 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/campaigns.py +90 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/change_history.py +194 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/creatives.py +206 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/geo.py +196 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/insights.py +141 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/keywords.py +91 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/product_pages.py +141 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/recommendations.py +339 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/reports.py +401 -0
- asa_api_client-0.3.0/asa_api_client/v1/resources/suggestions.py +194 -0
- asa_api_client-0.3.0/docs/guide/cli.md +48 -0
- asa_api_client-0.3.0/docs/guide/platform-api-v1.md +154 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/index.md +1 -1
- asa_api_client-0.3.0/docs/superpowers/plans/2026-08-05-analyze-cli.md +2587 -0
- asa_api_client-0.3.0/docs/superpowers/plans/2026-08-16-apple-ads-platform-api-v1.md +403 -0
- asa_api_client-0.3.0/docs/superpowers/specs/2026-08-05-analyze-cli-design.md +144 -0
- asa_api_client-0.3.0/docs/superpowers/specs/2026-08-16-apple-ads-platform-api-v1-design.md +149 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/mkdocs.yml +2 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/pyproject.toml +19 -1
- asa_api_client-0.3.0/tests/unit/cli/__init__.py +0 -0
- asa_api_client-0.3.0/tests/unit/cli/conftest.py +99 -0
- asa_api_client-0.3.0/tests/unit/cli/test_cli.py +112 -0
- asa_api_client-0.3.0/tests/unit/cli/test_dates.py +90 -0
- asa_api_client-0.3.0/tests/unit/cli/test_entry.py +45 -0
- asa_api_client-0.3.0/tests/unit/cli/test_fetch.py +364 -0
- asa_api_client-0.3.0/tests/unit/cli/test_metrics.py +202 -0
- asa_api_client-0.3.0/tests/unit/cli/test_v1_adapter.py +429 -0
- asa_api_client-0.3.0/tests/unit/cli/test_v1_smoke.py +245 -0
- asa_api_client-0.3.0/tests/unit/cli/test_workbook.py +225 -0
- asa_api_client-0.3.0/tests/unit/v1/__init__.py +0 -0
- asa_api_client-0.3.0/tests/unit/v1/conftest.py +31 -0
- asa_api_client-0.3.0/tests/unit/v1/test_ad_accounts.py +409 -0
- asa_api_client-0.3.0/tests/unit/v1/test_ad_groups.py +359 -0
- asa_api_client-0.3.0/tests/unit/v1/test_ads.py +264 -0
- asa_api_client-0.3.0/tests/unit/v1/test_apps.py +426 -0
- asa_api_client-0.3.0/tests/unit/v1/test_brands.py +496 -0
- asa_api_client-0.3.0/tests/unit/v1/test_budget_orders.py +336 -0
- asa_api_client-0.3.0/tests/unit/v1/test_bulk.py +474 -0
- asa_api_client-0.3.0/tests/unit/v1/test_campaigns.py +394 -0
- asa_api_client-0.3.0/tests/unit/v1/test_change_history.py +333 -0
- asa_api_client-0.3.0/tests/unit/v1/test_client.py +155 -0
- asa_api_client-0.3.0/tests/unit/v1/test_creatives_assets.py +476 -0
- asa_api_client-0.3.0/tests/unit/v1/test_exceptions.py +18 -0
- asa_api_client-0.3.0/tests/unit/v1/test_geo.py +313 -0
- asa_api_client-0.3.0/tests/unit/v1/test_insights.py +414 -0
- asa_api_client-0.3.0/tests/unit/v1/test_keywords.py +351 -0
- asa_api_client-0.3.0/tests/unit/v1/test_models_base.py +148 -0
- asa_api_client-0.3.0/tests/unit/v1/test_product_pages.py +302 -0
- asa_api_client-0.3.0/tests/unit/v1/test_query.py +113 -0
- asa_api_client-0.3.0/tests/unit/v1/test_recommendations.py +586 -0
- asa_api_client-0.3.0/tests/unit/v1/test_reports.py +639 -0
- asa_api_client-0.3.0/tests/unit/v1/test_resource_base.py +391 -0
- asa_api_client-0.3.0/tests/unit/v1/test_suggestions.py +305 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/uv.lock +203 -83
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.github/dependabot.yml +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.gitignore +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/.pre-commit-config.yaml +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/LICENSE +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/client.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/logging.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/__init__.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/acls.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/ad_groups.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/ads.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/base.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/budget_orders.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/countries.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/geo.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/keywords.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/models/product_pages.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/__init__.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/acls.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/ad_groups.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/ads.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/apps.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/budget_orders.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/campaigns.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/countries.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/custom_reports.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/geo.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/resources/keywords.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/settings.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/asa_api_client/utils/__init__.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/CNAME +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/client.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/exceptions.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/api/models.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/authentication.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/installation.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/getting-started/quickstart.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/ad-groups.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/async.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/campaigns.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/keywords.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/docs/guide/reports.md +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/tests/__init__.py +0 -0
- {asa_api_client-0.2.0 → asa_api_client-0.3.0}/tests/unit/__init__.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
22
|
|
|
23
23
|
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@
|
|
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@
|
|
38
|
+
uses: actions/configure-pages@v6
|
|
39
39
|
|
|
40
40
|
- name: Upload artifact
|
|
41
|
-
uses: actions/upload-pages-artifact@
|
|
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@
|
|
54
|
+
uses: actions/deploy-pages@v5
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: asa-api-client
|
|
3
|
-
Version: 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
|
[](https://github.com/SamPetherbridge/asa-api-client/blob/main/LICENSE)
|
|
49
59
|
[](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml)
|
|
50
60
|
|
|
51
|
-
A modern, fully-typed Python client for the Apple
|
|
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
|
-
|
|
267
|
+
Generate a formatted Excel analysis workbook with the included `asa analyze` CLI:
|
|
227
268
|
|
|
228
269
|
```bash
|
|
229
|
-
|
|
230
|
-
|
|
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
|
[](https://github.com/SamPetherbridge/asa-api-client/blob/main/LICENSE)
|
|
6
6
|
[](https://github.com/SamPetherbridge/asa-api-client/actions/workflows/ci.yml)
|
|
7
7
|
|
|
8
|
-
A modern, fully-typed Python client for the Apple
|
|
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
|
-
|
|
214
|
+
Generate a formatted Excel analysis workbook with the included `asa analyze` CLI:
|
|
184
215
|
|
|
185
216
|
```bash
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|