afly 0.1.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.
- afly-0.1.0/CHANGELOG.md +46 -0
- afly-0.1.0/LICENSE +21 -0
- afly-0.1.0/MANIFEST.in +4 -0
- afly-0.1.0/PKG-INFO +177 -0
- afly-0.1.0/README.md +134 -0
- afly-0.1.0/afly/__init__.py +5 -0
- afly-0.1.0/afly/alerting/__init__.py +3 -0
- afly-0.1.0/afly/alerting/webhook.py +92 -0
- afly-0.1.0/afly/appsflyer/__init__.py +1 -0
- afly-0.1.0/afly/appsflyer/client.py +83 -0
- afly-0.1.0/afly/appsflyer/errors.py +204 -0
- afly-0.1.0/afly/appsflyer/mng_api.py +98 -0
- afly-0.1.0/afly/appsflyer/pull_api.py +158 -0
- afly-0.1.0/afly/appsflyer/retry.py +95 -0
- afly-0.1.0/afly/cli/__init__.py +1 -0
- afly-0.1.0/afly/cli/_output.py +78 -0
- afly-0.1.0/afly/cli/_project.py +114 -0
- afly-0.1.0/afly/cli/assets/claude/CLAUDE.section.md +86 -0
- afly-0.1.0/afly/cli/assets/claude/rules/cli.md +246 -0
- afly-0.1.0/afly/cli/assets/claude/rules/extracts.md +130 -0
- afly-0.1.0/afly/cli/assets/claude/rules/formats.md +116 -0
- afly-0.1.0/afly/cli/assets/claude/rules/idempotency.md +200 -0
- afly-0.1.0/afly/cli/assets/claude/rules/overview.md +76 -0
- afly-0.1.0/afly/cli/assets/claude/rules/project.md +147 -0
- afly-0.1.0/afly/cli/assets/claude/rules/quotas.md +179 -0
- afly-0.1.0/afly/cli/assets/claude/skills/afly-backfill/SKILL.md +68 -0
- afly-0.1.0/afly/cli/assets/claude/skills/afly-debug-run/SKILL.md +70 -0
- afly-0.1.0/afly/cli/assets/claude/skills/afly-new-extract/SKILL.md +75 -0
- afly-0.1.0/afly/cli/assets/claude/skills/afly-setup-project/SKILL.md +62 -0
- afly-0.1.0/afly/cli/commands/__init__.py +8 -0
- afly-0.1.0/afly/cli/commands/apps.py +84 -0
- afly-0.1.0/afly/cli/commands/debug.py +279 -0
- afly-0.1.0/afly/cli/commands/init.py +228 -0
- afly-0.1.0/afly/cli/commands/init_claude.py +191 -0
- afly-0.1.0/afly/cli/commands/ls.py +67 -0
- afly-0.1.0/afly/cli/commands/run.py +92 -0
- afly-0.1.0/afly/cli/commands/unlock.py +74 -0
- afly-0.1.0/afly/cli/commands/validate.py +73 -0
- afly-0.1.0/afly/cli/main.py +249 -0
- afly-0.1.0/afly/config/__init__.py +20 -0
- afly-0.1.0/afly/config/discovery.py +86 -0
- afly-0.1.0/afly/config/extract_config.py +286 -0
- afly-0.1.0/afly/config/profile.py +235 -0
- afly-0.1.0/afly/config/project_config.py +168 -0
- afly-0.1.0/afly/config/selectors.py +65 -0
- afly-0.1.0/afly/csvmap/__init__.py +1 -0
- afly-0.1.0/afly/csvmap/currency.py +58 -0
- afly-0.1.0/afly/csvmap/headers.py +163 -0
- afly-0.1.0/afly/csvmap/parser.py +212 -0
- afly-0.1.0/afly/csvmap/values.py +81 -0
- afly-0.1.0/afly/database/__init__.py +1 -0
- afly-0.1.0/afly/database/_http_client.py +186 -0
- afly-0.1.0/afly/database/checks.py +257 -0
- afly-0.1.0/afly/database/clickhouse.py +251 -0
- afly-0.1.0/afly/database/ddl.py +197 -0
- afly-0.1.0/afly/database/loads.py +159 -0
- afly-0.1.0/afly/database/locks.py +157 -0
- afly-0.1.0/afly/database/tables.py +87 -0
- afly-0.1.0/afly/database/writer.py +205 -0
- afly-0.1.0/afly/py.typed +0 -0
- afly-0.1.0/afly/run/__init__.py +5 -0
- afly-0.1.0/afly/run/_alert.py +66 -0
- afly-0.1.0/afly/run/_apps.py +61 -0
- afly-0.1.0/afly/run/_deps.py +113 -0
- afly-0.1.0/afly/run/_echo.py +34 -0
- afly-0.1.0/afly/run/_fetch.py +157 -0
- afly-0.1.0/afly/run/_job_result.py +52 -0
- afly-0.1.0/afly/run/_protocols.py +54 -0
- afly-0.1.0/afly/run/_rebuild.py +139 -0
- afly-0.1.0/afly/run/_render.py +80 -0
- afly-0.1.0/afly/run/_setup.py +82 -0
- afly-0.1.0/afly/run/_wave_window.py +71 -0
- afly-0.1.0/afly/run/executor.py +413 -0
- afly-0.1.0/afly/run/options.py +30 -0
- afly-0.1.0/afly/run/planner.py +230 -0
- afly-0.1.0/afly/run/runner.py +318 -0
- afly-0.1.0/afly/run/scheduler.py +367 -0
- afly-0.1.0/afly/run/summary.py +98 -0
- afly-0.1.0/afly/run/windows.py +125 -0
- afly-0.1.0/afly/schema.py +109 -0
- afly-0.1.0/afly/utils/__init__.py +1 -0
- afly-0.1.0/afly/utils/datetime_utils.py +67 -0
- afly-0.1.0/afly/utils/env_interpolation.py +81 -0
- afly-0.1.0/afly/utils/naming.py +49 -0
- afly-0.1.0/afly.egg-info/PKG-INFO +177 -0
- afly-0.1.0/afly.egg-info/SOURCES.txt +90 -0
- afly-0.1.0/afly.egg-info/dependency_links.txt +1 -0
- afly-0.1.0/afly.egg-info/entry_points.txt +2 -0
- afly-0.1.0/afly.egg-info/requires.txt +22 -0
- afly-0.1.0/afly.egg-info/top_level.txt +1 -0
- afly-0.1.0/pyproject.toml +105 -0
- afly-0.1.0/setup.cfg +4 -0
afly-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
First public release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `afly init <name>` — scaffold a project: `afly_project.yml`, `profiles.yml`
|
|
17
|
+
(credentials via `{{ env_var('…') }}` / `${…}`), `.env.example`, and three
|
|
18
|
+
starter extracts (`standard`, `facebook`, `yandex`) demonstrating the
|
|
19
|
+
`exclude_media_sources` ownership convention.
|
|
20
|
+
- `afly run` — idempotent AppsFlyer aggregate Pull API → ClickHouse extraction:
|
|
21
|
+
- watermark-based windows with per-extract `lookback_days`, epoch-aligned
|
|
22
|
+
`chunk_days` chunks, `--from/--to/--full-refresh`;
|
|
23
|
+
- partition rebuild via staging + `REPLACE PARTITION` (or `DROP PARTITION`
|
|
24
|
+
when nothing remains): no duplicates, no `FINAL` for readers, rows that
|
|
25
|
+
vanish from AppsFlyer vanish from the table, no row-level dedup (rows
|
|
26
|
+
identical on every dimension within one pull are all kept);
|
|
27
|
+
- `partition_granularity: month | day` (default `month`);
|
|
28
|
+
- quota-aware scheduler: per-key spacing, daily long-call budgets,
|
|
29
|
+
rate-limited jobs deferred without blocking other apps, lookahead across
|
|
30
|
+
waves (`max_waves_in_flight`), `--max-calls/--max-minutes`;
|
|
31
|
+
- empty-response guard, per-table locks, `--dry-run`, `--json` summary.
|
|
32
|
+
- One destination table for all apps of an account, snake_case columns with
|
|
33
|
+
`app_id`, Facebook campaign/adset/adgroup detail, in-app events as
|
|
34
|
+
`Map` columns, and a `currency` column (AppsFlyer reports money in each
|
|
35
|
+
app's own currency).
|
|
36
|
+
- ClickHouse over the native protocol or HTTP (`protocol: native | http`),
|
|
37
|
+
tested on ClickHouse 22.11 and 26.3; optional separate `internal_database`
|
|
38
|
+
and `staging_database`.
|
|
39
|
+
- `afly ls`, `afly validate`, `afly apps`, `afly debug` (connectivity, grants,
|
|
40
|
+
`--deep` partition-swap drill, `--pull` sample pull), `afly unlock`.
|
|
41
|
+
- `afly init-claude` — Claude Code context (`CLAUDE.md` block,
|
|
42
|
+
`.claude/rules/afly/`, four skills), idempotent and refreshable.
|
|
43
|
+
- Failure alerting to Mattermost/Slack/webhook, once per run, never on quota
|
|
44
|
+
or policy skips.
|
|
45
|
+
- Documentation in `docs/`: getting started, configuration, extracts, formats
|
|
46
|
+
and the Facebook split, idempotency, quotas, scheduling, alerting.
|
afly-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 selishchev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
afly-0.1.0/MANIFEST.in
ADDED
afly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Idempotent AppsFlyer aggregate Pull API → ClickHouse extractor with a dbt-style CLI
|
|
5
|
+
Author: selishchev
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/selishchev/afly
|
|
8
|
+
Project-URL: Repository, https://github.com/selishchev/afly
|
|
9
|
+
Project-URL: Changelog, https://github.com/selishchev/afly/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: appsflyer,clickhouse,etl,marketing,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: click>=8.0
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Requires-Dist: requests>=2.25.0
|
|
26
|
+
Requires-Dist: clickhouse-driver>=0.2.6
|
|
27
|
+
Requires-Dist: clickhouse-connect>=0.8
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
31
|
+
Requires-Dist: requests-mock; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff; extra == "dev"
|
|
33
|
+
Requires-Dist: black; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy; extra == "dev"
|
|
35
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
36
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
37
|
+
Requires-Dist: build; extra == "dev"
|
|
38
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
39
|
+
Requires-Dist: detect-secrets; extra == "dev"
|
|
40
|
+
Provides-Extra: integration
|
|
41
|
+
Requires-Dist: testcontainers[clickhouse]>=4.0; extra == "integration"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# afly
|
|
45
|
+
|
|
46
|
+
[](https://github.com/selishchev/afly/actions/workflows/ci.yml)
|
|
47
|
+
[](https://pypi.org/project/afly/)
|
|
48
|
+
[](https://pypi.org/project/afly/)
|
|
49
|
+
|
|
50
|
+
**Idempotent AppsFlyer aggregate reports → ClickHouse, with a dbt-style
|
|
51
|
+
CLI.**
|
|
52
|
+
|
|
53
|
+
`afly` pulls AppsFlyer's [aggregate Pull
|
|
54
|
+
API](https://support.appsflyer.com/hc/en-us/articles/207034346-Pull-API-aggregate-data) reports and
|
|
55
|
+
writes them into ClickHouse. It's a dbt/detectkit-style project: an
|
|
56
|
+
`afly_project.yml` describes the project, `profiles.yml` holds credentials,
|
|
57
|
+
and `extracts/*.yml` declare which reports to pull and where they land.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Why
|
|
61
|
+
|
|
62
|
+
- **Idempotent by construction, not by convention.** Every run rebuilds
|
|
63
|
+
whole ClickHouse partitions atomically (`REPLACE PARTITION`) instead of
|
|
64
|
+
appending — re-running the same window, or recovering from a crash mid-run,
|
|
65
|
+
never duplicates a row and never needs `FINAL` on read.
|
|
66
|
+
- **Quota-aware.** AppsFlyer enforces a per-minute limit and a small daily
|
|
67
|
+
budget for wider date ranges. afly's scheduler interleaves apps/report
|
|
68
|
+
types to stay under both automatically, and a backfill can be capped with
|
|
69
|
+
`--max-calls`/`--max-minutes` and safely resumed later.
|
|
70
|
+
- **The Facebook split is a config concern, not a bug.** AppsFlyer only
|
|
71
|
+
returns campaign/adset/adgroup breakdown columns for a Facebook-scoped
|
|
72
|
+
pull — afly's scaffold ships a dedicated `facebook` extract alongside an
|
|
73
|
+
unfiltered one that explicitly excludes it, so the ownership is explicit
|
|
74
|
+
instead of accidentally double-counted.
|
|
75
|
+
- **A `--dry-run` you can actually trust.** Before pulling anything, `afly
|
|
76
|
+
run --dry-run` prints the exact window/chunk plan and the AppsFlyer quota
|
|
77
|
+
totals it would spend, flagging anything that would exceed budget.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install afly
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Requires Python 3.10+.
|
|
86
|
+
|
|
87
|
+
## 60-second quickstart
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
afly init my_project && cd my_project
|
|
91
|
+
cp .env.example .env # fill in AppsFlyer token + ClickHouse credentials
|
|
92
|
+
set -a; source .env; set +a
|
|
93
|
+
|
|
94
|
+
afly validate # config sanity check, no network calls
|
|
95
|
+
afly debug # probe AppsFlyer/ClickHouse connectivity
|
|
96
|
+
afly run --select "*" --dry-run # print the plan, pull nothing
|
|
97
|
+
afly run --select "*" # pull for real
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```sql
|
|
101
|
+
SELECT date, media_source, sum(total_cost)
|
|
102
|
+
FROM appsflyer.appsflyer_geo_by_date
|
|
103
|
+
GROUP BY 1, 2
|
|
104
|
+
ORDER BY 1, 2;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See [Quickstart](docs/getting-started/quickstart.md) for the full walkthrough.
|
|
108
|
+
|
|
109
|
+
## The three-extract picture
|
|
110
|
+
|
|
111
|
+
`afly init` scaffolds a realistic split that demonstrates the ownership
|
|
112
|
+
convention every extra media-source extract should follow:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
# extracts/standard.yml — everything else
|
|
116
|
+
name: standard
|
|
117
|
+
report_type: geo_by_date_report
|
|
118
|
+
exclude_media_sources: [Facebook Ads, yandexdirect_int] # owned below
|
|
119
|
+
table: appsflyer_geo_by_date
|
|
120
|
+
|
|
121
|
+
# extracts/facebook.yml — needs its own scoped pull for adset/adgroup columns
|
|
122
|
+
name: facebook
|
|
123
|
+
media_source: facebook
|
|
124
|
+
table: appsflyer_geo_by_date
|
|
125
|
+
|
|
126
|
+
# extracts/yandex.yml — reports arrive late, so re-pull a wider window
|
|
127
|
+
name: yandex
|
|
128
|
+
media_source: yandexdirect_int
|
|
129
|
+
lookback_days: 7
|
|
130
|
+
table: appsflyer_geo_by_date
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
All three write the same table safely, because `standard` explicitly excludes
|
|
134
|
+
what the other two own. See [Extracts
|
|
135
|
+
guide](docs/guides/extracts.md) and [Facebook
|
|
136
|
+
split](docs/guides/formats-and-facebook-split.md).
|
|
137
|
+
|
|
138
|
+
## AI-native onboarding
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
afly init-claude
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Scaffolds `CLAUDE.md` + `.claude/rules/afly/` + four skills
|
|
145
|
+
(`afly-setup-project`, `afly-new-extract`, `afly-backfill`,
|
|
146
|
+
`afly-debug-run`) into the project, so Claude Code (or another AI assistant)
|
|
147
|
+
can configure extracts, size a backfill against the AppsFlyer quota, and
|
|
148
|
+
debug a failing run with the real reference instead of guessing. Idempotent —
|
|
149
|
+
safe to re-run after upgrading afly. See [Claude Code
|
|
150
|
+
guide](docs/guides/claude-code.md).
|
|
151
|
+
|
|
152
|
+
## Documentation
|
|
153
|
+
|
|
154
|
+
- [Installation](docs/getting-started/installation.md)
|
|
155
|
+
- [Quickstart](docs/getting-started/quickstart.md)
|
|
156
|
+
- [Configuration](docs/guides/configuration.md)
|
|
157
|
+
- [Extracts](docs/guides/extracts.md)
|
|
158
|
+
- [Formats & the Facebook split](docs/guides/formats-and-facebook-split.md)
|
|
159
|
+
- [Idempotency (the write path)](docs/guides/idempotency.md)
|
|
160
|
+
- [Quotas & scheduling](docs/guides/quotas.md)
|
|
161
|
+
- [Running on a schedule](docs/guides/scheduling.md)
|
|
162
|
+
- [Alerting](docs/guides/alerting.md)
|
|
163
|
+
- [Claude Code](docs/guides/claude-code.md)
|
|
164
|
+
- [CLI reference](docs/reference/cli.md)
|
|
165
|
+
- [Config reference](docs/reference/config.md)
|
|
166
|
+
- [Tables reference](docs/reference/tables.md)
|
|
167
|
+
- [Changelog](CHANGELOG.md)
|
|
168
|
+
|
|
169
|
+
## Requirements
|
|
170
|
+
|
|
171
|
+
- Python 3.10+
|
|
172
|
+
- ClickHouse (tested against 22.11)
|
|
173
|
+
- An AppsFlyer account with Pull API (API V2) access
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
afly-0.1.0/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# afly
|
|
2
|
+
|
|
3
|
+
[](https://github.com/selishchev/afly/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/afly/)
|
|
5
|
+
[](https://pypi.org/project/afly/)
|
|
6
|
+
|
|
7
|
+
**Idempotent AppsFlyer aggregate reports → ClickHouse, with a dbt-style
|
|
8
|
+
CLI.**
|
|
9
|
+
|
|
10
|
+
`afly` pulls AppsFlyer's [aggregate Pull
|
|
11
|
+
API](https://support.appsflyer.com/hc/en-us/articles/207034346-Pull-API-aggregate-data) reports and
|
|
12
|
+
writes them into ClickHouse. It's a dbt/detectkit-style project: an
|
|
13
|
+
`afly_project.yml` describes the project, `profiles.yml` holds credentials,
|
|
14
|
+
and `extracts/*.yml` declare which reports to pull and where they land.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
- **Idempotent by construction, not by convention.** Every run rebuilds
|
|
20
|
+
whole ClickHouse partitions atomically (`REPLACE PARTITION`) instead of
|
|
21
|
+
appending — re-running the same window, or recovering from a crash mid-run,
|
|
22
|
+
never duplicates a row and never needs `FINAL` on read.
|
|
23
|
+
- **Quota-aware.** AppsFlyer enforces a per-minute limit and a small daily
|
|
24
|
+
budget for wider date ranges. afly's scheduler interleaves apps/report
|
|
25
|
+
types to stay under both automatically, and a backfill can be capped with
|
|
26
|
+
`--max-calls`/`--max-minutes` and safely resumed later.
|
|
27
|
+
- **The Facebook split is a config concern, not a bug.** AppsFlyer only
|
|
28
|
+
returns campaign/adset/adgroup breakdown columns for a Facebook-scoped
|
|
29
|
+
pull — afly's scaffold ships a dedicated `facebook` extract alongside an
|
|
30
|
+
unfiltered one that explicitly excludes it, so the ownership is explicit
|
|
31
|
+
instead of accidentally double-counted.
|
|
32
|
+
- **A `--dry-run` you can actually trust.** Before pulling anything, `afly
|
|
33
|
+
run --dry-run` prints the exact window/chunk plan and the AppsFlyer quota
|
|
34
|
+
totals it would spend, flagging anything that would exceed budget.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install afly
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+.
|
|
43
|
+
|
|
44
|
+
## 60-second quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
afly init my_project && cd my_project
|
|
48
|
+
cp .env.example .env # fill in AppsFlyer token + ClickHouse credentials
|
|
49
|
+
set -a; source .env; set +a
|
|
50
|
+
|
|
51
|
+
afly validate # config sanity check, no network calls
|
|
52
|
+
afly debug # probe AppsFlyer/ClickHouse connectivity
|
|
53
|
+
afly run --select "*" --dry-run # print the plan, pull nothing
|
|
54
|
+
afly run --select "*" # pull for real
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```sql
|
|
58
|
+
SELECT date, media_source, sum(total_cost)
|
|
59
|
+
FROM appsflyer.appsflyer_geo_by_date
|
|
60
|
+
GROUP BY 1, 2
|
|
61
|
+
ORDER BY 1, 2;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See [Quickstart](docs/getting-started/quickstart.md) for the full walkthrough.
|
|
65
|
+
|
|
66
|
+
## The three-extract picture
|
|
67
|
+
|
|
68
|
+
`afly init` scaffolds a realistic split that demonstrates the ownership
|
|
69
|
+
convention every extra media-source extract should follow:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
# extracts/standard.yml — everything else
|
|
73
|
+
name: standard
|
|
74
|
+
report_type: geo_by_date_report
|
|
75
|
+
exclude_media_sources: [Facebook Ads, yandexdirect_int] # owned below
|
|
76
|
+
table: appsflyer_geo_by_date
|
|
77
|
+
|
|
78
|
+
# extracts/facebook.yml — needs its own scoped pull for adset/adgroup columns
|
|
79
|
+
name: facebook
|
|
80
|
+
media_source: facebook
|
|
81
|
+
table: appsflyer_geo_by_date
|
|
82
|
+
|
|
83
|
+
# extracts/yandex.yml — reports arrive late, so re-pull a wider window
|
|
84
|
+
name: yandex
|
|
85
|
+
media_source: yandexdirect_int
|
|
86
|
+
lookback_days: 7
|
|
87
|
+
table: appsflyer_geo_by_date
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
All three write the same table safely, because `standard` explicitly excludes
|
|
91
|
+
what the other two own. See [Extracts
|
|
92
|
+
guide](docs/guides/extracts.md) and [Facebook
|
|
93
|
+
split](docs/guides/formats-and-facebook-split.md).
|
|
94
|
+
|
|
95
|
+
## AI-native onboarding
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
afly init-claude
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Scaffolds `CLAUDE.md` + `.claude/rules/afly/` + four skills
|
|
102
|
+
(`afly-setup-project`, `afly-new-extract`, `afly-backfill`,
|
|
103
|
+
`afly-debug-run`) into the project, so Claude Code (or another AI assistant)
|
|
104
|
+
can configure extracts, size a backfill against the AppsFlyer quota, and
|
|
105
|
+
debug a failing run with the real reference instead of guessing. Idempotent —
|
|
106
|
+
safe to re-run after upgrading afly. See [Claude Code
|
|
107
|
+
guide](docs/guides/claude-code.md).
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
- [Installation](docs/getting-started/installation.md)
|
|
112
|
+
- [Quickstart](docs/getting-started/quickstart.md)
|
|
113
|
+
- [Configuration](docs/guides/configuration.md)
|
|
114
|
+
- [Extracts](docs/guides/extracts.md)
|
|
115
|
+
- [Formats & the Facebook split](docs/guides/formats-and-facebook-split.md)
|
|
116
|
+
- [Idempotency (the write path)](docs/guides/idempotency.md)
|
|
117
|
+
- [Quotas & scheduling](docs/guides/quotas.md)
|
|
118
|
+
- [Running on a schedule](docs/guides/scheduling.md)
|
|
119
|
+
- [Alerting](docs/guides/alerting.md)
|
|
120
|
+
- [Claude Code](docs/guides/claude-code.md)
|
|
121
|
+
- [CLI reference](docs/reference/cli.md)
|
|
122
|
+
- [Config reference](docs/reference/config.md)
|
|
123
|
+
- [Tables reference](docs/reference/tables.md)
|
|
124
|
+
- [Changelog](CHANGELOG.md)
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Python 3.10+
|
|
129
|
+
- ClickHouse (tested against 22.11)
|
|
130
|
+
- An AppsFlyer account with Pull API (API V2) access
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Failure-alert delivery over a webhook (Mattermost/Slack "attachments", or plain JSON).
|
|
2
|
+
|
|
3
|
+
This only ever fires for a *run-level* failure (the pipeline itself broke —
|
|
4
|
+
auth, ClickHouse, an aborted run) — see
|
|
5
|
+
``afly.config.project_config.ErrorAlertingConfig`` and
|
|
6
|
+
``afly.run.runner._send_alerts``. It is not a data-quality alert.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from afly import __version__
|
|
18
|
+
from afly.config.profile import AlertChannelConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_ATTACHMENT_COLOR = "#D63232"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def send_failure_alert(
|
|
26
|
+
channel: AlertChannelConfig,
|
|
27
|
+
*,
|
|
28
|
+
project: str,
|
|
29
|
+
profile: str,
|
|
30
|
+
run_id: str,
|
|
31
|
+
title: str,
|
|
32
|
+
lines: Sequence[str],
|
|
33
|
+
mentions: Sequence[str] = (),
|
|
34
|
+
post: Callable[..., Any] = requests.post,
|
|
35
|
+
) -> bool:
|
|
36
|
+
"""POST a failure alert to *channel*. Never raises — returns ``False`` and
|
|
37
|
+
logs a warning on any failure (network error, non-2xx, bad channel config).
|
|
38
|
+
|
|
39
|
+
The webhook URL (which may itself embed a token, per Mattermost/Slack
|
|
40
|
+
convention) is only ever used as the POST target — nothing here echoes
|
|
41
|
+
any credential back into the message body.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
payload = _build_payload(
|
|
45
|
+
channel, project=project, run_id=run_id, title=title, lines=lines, mentions=mentions
|
|
46
|
+
)
|
|
47
|
+
response = post(channel.webhook_url, json=payload, timeout=channel.timeout)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
return True
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"failed to send failure alert via %s channel for run %s",
|
|
53
|
+
channel.type,
|
|
54
|
+
run_id,
|
|
55
|
+
exc_info=True,
|
|
56
|
+
)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_payload(
|
|
61
|
+
channel: AlertChannelConfig,
|
|
62
|
+
*,
|
|
63
|
+
project: str,
|
|
64
|
+
run_id: str,
|
|
65
|
+
title: str,
|
|
66
|
+
lines: Sequence[str],
|
|
67
|
+
mentions: Sequence[str],
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
if channel.type == "webhook":
|
|
70
|
+
return {"title": title, "text": "\n".join(lines), "project": project, "run_id": run_id}
|
|
71
|
+
|
|
72
|
+
# mattermost / slack: both speak the same incoming-webhook "attachments" shape.
|
|
73
|
+
payload: dict[str, Any] = {
|
|
74
|
+
"username": channel.username,
|
|
75
|
+
"text": " ".join(mentions),
|
|
76
|
+
"attachments": [
|
|
77
|
+
{
|
|
78
|
+
"color": _ATTACHMENT_COLOR,
|
|
79
|
+
"title": title,
|
|
80
|
+
"text": "\n".join(lines),
|
|
81
|
+
"footer": f"afly {__version__}",
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
if channel.icon_emoji:
|
|
86
|
+
payload["icon_emoji"] = channel.icon_emoji
|
|
87
|
+
if channel.channel:
|
|
88
|
+
payload["channel"] = channel.channel
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["send_failure_alert"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AppsFlyer HTTP client: errors, retry policy, and the Pull/management APIs."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Thin HTTP wrapper around AppsFlyer's REST APIs.
|
|
2
|
+
|
|
3
|
+
Deliberately does *not* interpret response status codes — that's
|
|
4
|
+
``afly.appsflyer.errors.classify_response``'s job, applied by the caller
|
|
5
|
+
(``pull_api``/``mng_api``) once it knows which endpoint's error semantics
|
|
6
|
+
apply. This class only owns: auth headers, timeouts, redirect-following, and
|
|
7
|
+
turning a network-level failure (not an HTTP error response) into a
|
|
8
|
+
:class:`TransientError` the retry policy already knows how to handle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from afly import __version__
|
|
19
|
+
from afly.appsflyer.errors import TransientError
|
|
20
|
+
|
|
21
|
+
_DEFAULT_BASE_URL = "https://hq1.appsflyer.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AppsFlyerClient:
|
|
25
|
+
"""Authenticated HTTP client for one AppsFlyer account (one token)."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
token: str,
|
|
30
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
31
|
+
timeout_seconds: int = 120,
|
|
32
|
+
user_agent: str | None = None,
|
|
33
|
+
session: requests.Session | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._token = token
|
|
36
|
+
self.base_url = base_url.rstrip("/")
|
|
37
|
+
self.timeout_seconds = timeout_seconds
|
|
38
|
+
self.user_agent = user_agent or f"afly/{__version__}"
|
|
39
|
+
self._session = session or requests.Session()
|
|
40
|
+
self.api_calls = 0
|
|
41
|
+
|
|
42
|
+
def reset_counter(self) -> None:
|
|
43
|
+
"""Zero the ``api_calls`` counter (e.g. between extracts in a run)."""
|
|
44
|
+
self.api_calls = 0
|
|
45
|
+
|
|
46
|
+
def get(
|
|
47
|
+
self,
|
|
48
|
+
path: str,
|
|
49
|
+
params: Mapping[str, Any] | None = None,
|
|
50
|
+
accept: str = "application/json",
|
|
51
|
+
) -> requests.Response:
|
|
52
|
+
"""GET ``base_url + path``, following redirects, with afly's standard headers.
|
|
53
|
+
|
|
54
|
+
Increments ``api_calls`` exactly once per call — including a call
|
|
55
|
+
that raises, since the network round-trip still happened and counts
|
|
56
|
+
against AppsFlyer's rate limit either way.
|
|
57
|
+
"""
|
|
58
|
+
url = f"{self.base_url}{path}"
|
|
59
|
+
headers = {
|
|
60
|
+
"authorization": f"Bearer {self._token}",
|
|
61
|
+
"accept": accept,
|
|
62
|
+
"user-agent": self.user_agent,
|
|
63
|
+
}
|
|
64
|
+
try:
|
|
65
|
+
response = self._session.get(
|
|
66
|
+
url,
|
|
67
|
+
params=params,
|
|
68
|
+
headers=headers,
|
|
69
|
+
timeout=self.timeout_seconds,
|
|
70
|
+
allow_redirects=True,
|
|
71
|
+
)
|
|
72
|
+
except requests.RequestException as exc:
|
|
73
|
+
self.api_calls += 1
|
|
74
|
+
raise TransientError(
|
|
75
|
+
f"network error calling AppsFlyer: {exc}", status=None, body=str(exc), url=url
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
self.api_calls += 1
|
|
79
|
+
return response
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
masked = f"{self._token[:4]}…" if self._token else "(empty)"
|
|
83
|
+
return f"AppsFlyerClient(base_url={self.base_url!r}, token={masked!r})"
|