trackofferz 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.
- trackofferz-0.1.0/PKG-INFO +100 -0
- trackofferz-0.1.0/README.md +83 -0
- trackofferz-0.1.0/examples/quickstart.py +35 -0
- trackofferz-0.1.0/pyproject.toml +29 -0
- trackofferz-0.1.0/trackofferz/__init__.py +71 -0
- trackofferz-0.1.0/trackofferz/_client.py +120 -0
- trackofferz-0.1.0/trackofferz/errors.py +38 -0
- trackofferz-0.1.0/trackofferz/py.typed +0 -0
- trackofferz-0.1.0/trackofferz/reports.py +118 -0
- trackofferz-0.1.0/trackofferz/types.py +160 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trackofferz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the TrackOfferz reporting API.
|
|
5
|
+
Project-URL: Homepage, https://trackofferz.com
|
|
6
|
+
Project-URL: Documentation, https://trackofferz.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/trackofferz/trackofferz-python
|
|
8
|
+
Author-email: SPTSPL <support@trackofferz.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: affiliate,reporting,sdk,tracking,trackofferz
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# TrackOfferz Python SDK
|
|
19
|
+
|
|
20
|
+
Official, zero-dependency Python client for the [TrackOfferz](https://trackofferz.com) reporting API.
|
|
21
|
+
|
|
22
|
+
- **Typed** — dataclass results, ships with `py.typed`.
|
|
23
|
+
- **Zero dependencies** — standard library only.
|
|
24
|
+
- **Resilient** — automatic retries on 429 (honours `Retry-After`) and transient 5xx.
|
|
25
|
+
- **Network-scoped** — every call is limited to your network by the API key.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install trackofferz
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Authenticate
|
|
34
|
+
|
|
35
|
+
Create a key in the panel under **Settings → API keys** (format `tofz_sk_…`).
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from trackofferz import TrackOfferz
|
|
39
|
+
|
|
40
|
+
client = TrackOfferz(api_key="tofz_sk_...")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Reporting
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# Summary (defaults to the last 30 days)
|
|
47
|
+
s = client.reports.summary(from_="2026-06-01", to="2026-06-30")
|
|
48
|
+
print(s.clicks, s.conversions, s.revenue_cents)
|
|
49
|
+
|
|
50
|
+
# Breakdown by publisher
|
|
51
|
+
for row in client.reports.breakdown(by="publisher"):
|
|
52
|
+
print(row.label, row.conversions, row.payout_cents)
|
|
53
|
+
|
|
54
|
+
# One page of conversions for a campaign
|
|
55
|
+
page = client.reports.conversions(campaign_id=5, page=1, page_size=100)
|
|
56
|
+
print(page.total, page.has_next)
|
|
57
|
+
for c in page.rows:
|
|
58
|
+
print(c.transaction_id, c.payout_cents, c.status)
|
|
59
|
+
|
|
60
|
+
# Stream EVERY click across all pages (auto-pagination)
|
|
61
|
+
for click in client.reports.iter_clicks(from_="2026-06-01", to="2026-06-30"):
|
|
62
|
+
print(click.transaction_id, click.country_code, click.is_unique)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> Money fields are integers in **cents** (`payout_cents=1250` → `$12.50`).
|
|
66
|
+
> Date params are `YYYY-MM-DD`. `from_` has a trailing underscore (`from` is a Python keyword) and maps to the `from` query parameter.
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from trackofferz import (
|
|
72
|
+
AuthenticationError, ValidationError, RateLimitError, APIError, TrackOfferzError,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
client.reports.summary(from_="not-a-date")
|
|
77
|
+
except ValidationError as e:
|
|
78
|
+
print("bad input:", e.message)
|
|
79
|
+
except RateLimitError as e:
|
|
80
|
+
print("slow down; retry after", e.retry_after, "s")
|
|
81
|
+
except TrackOfferzError as e: # base class — catches all of the above
|
|
82
|
+
print(e.status, e.code, e.message)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
TrackOfferz(
|
|
89
|
+
api_key="tofz_sk_...",
|
|
90
|
+
base_url="https://trackofferz.com/api/v1", # override for self-hosted
|
|
91
|
+
timeout=30.0,
|
|
92
|
+
max_retries=2,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Full API reference: <https://trackofferz.com/docs>
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
TrackOfferz is a product by [SPTSPL (S. P. Techno Solution Private Limited)](https://www.sptspl.com).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# TrackOfferz Python SDK
|
|
2
|
+
|
|
3
|
+
Official, zero-dependency Python client for the [TrackOfferz](https://trackofferz.com) reporting API.
|
|
4
|
+
|
|
5
|
+
- **Typed** — dataclass results, ships with `py.typed`.
|
|
6
|
+
- **Zero dependencies** — standard library only.
|
|
7
|
+
- **Resilient** — automatic retries on 429 (honours `Retry-After`) and transient 5xx.
|
|
8
|
+
- **Network-scoped** — every call is limited to your network by the API key.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install trackofferz
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Authenticate
|
|
17
|
+
|
|
18
|
+
Create a key in the panel under **Settings → API keys** (format `tofz_sk_…`).
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from trackofferz import TrackOfferz
|
|
22
|
+
|
|
23
|
+
client = TrackOfferz(api_key="tofz_sk_...")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Reporting
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# Summary (defaults to the last 30 days)
|
|
30
|
+
s = client.reports.summary(from_="2026-06-01", to="2026-06-30")
|
|
31
|
+
print(s.clicks, s.conversions, s.revenue_cents)
|
|
32
|
+
|
|
33
|
+
# Breakdown by publisher
|
|
34
|
+
for row in client.reports.breakdown(by="publisher"):
|
|
35
|
+
print(row.label, row.conversions, row.payout_cents)
|
|
36
|
+
|
|
37
|
+
# One page of conversions for a campaign
|
|
38
|
+
page = client.reports.conversions(campaign_id=5, page=1, page_size=100)
|
|
39
|
+
print(page.total, page.has_next)
|
|
40
|
+
for c in page.rows:
|
|
41
|
+
print(c.transaction_id, c.payout_cents, c.status)
|
|
42
|
+
|
|
43
|
+
# Stream EVERY click across all pages (auto-pagination)
|
|
44
|
+
for click in client.reports.iter_clicks(from_="2026-06-01", to="2026-06-30"):
|
|
45
|
+
print(click.transaction_id, click.country_code, click.is_unique)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> Money fields are integers in **cents** (`payout_cents=1250` → `$12.50`).
|
|
49
|
+
> Date params are `YYYY-MM-DD`. `from_` has a trailing underscore (`from` is a Python keyword) and maps to the `from` query parameter.
|
|
50
|
+
|
|
51
|
+
## Errors
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from trackofferz import (
|
|
55
|
+
AuthenticationError, ValidationError, RateLimitError, APIError, TrackOfferzError,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
client.reports.summary(from_="not-a-date")
|
|
60
|
+
except ValidationError as e:
|
|
61
|
+
print("bad input:", e.message)
|
|
62
|
+
except RateLimitError as e:
|
|
63
|
+
print("slow down; retry after", e.retry_after, "s")
|
|
64
|
+
except TrackOfferzError as e: # base class — catches all of the above
|
|
65
|
+
print(e.status, e.code, e.message)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
TrackOfferz(
|
|
72
|
+
api_key="tofz_sk_...",
|
|
73
|
+
base_url="https://trackofferz.com/api/v1", # override for self-hosted
|
|
74
|
+
timeout=30.0,
|
|
75
|
+
max_retries=2,
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Full API reference: <https://trackofferz.com/docs>
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
TrackOfferz is a product by [SPTSPL (S. P. Techno Solution Private Limited)](https://www.sptspl.com).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Quickstart for the TrackOfferz Python SDK.
|
|
2
|
+
|
|
3
|
+
export TOFZ_API_KEY=tofz_sk_...
|
|
4
|
+
python examples/quickstart.py
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from trackofferz import TrackOfferz, TrackOfferzError
|
|
9
|
+
|
|
10
|
+
API_KEY = os.environ.get("TOFZ_API_KEY", "")
|
|
11
|
+
# Point at a non-production host if needed:
|
|
12
|
+
BASE_URL = os.environ.get("TOFZ_BASE_URL", "https://trackofferz.com/api/v1")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
if not API_KEY:
|
|
17
|
+
raise SystemExit("Set TOFZ_API_KEY (Settings → API keys in the panel).")
|
|
18
|
+
|
|
19
|
+
client = TrackOfferz(api_key=API_KEY, base_url=BASE_URL)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
s = client.reports.summary()
|
|
23
|
+
print(f"Last 30 days: {s.clicks} clicks, {s.unique_clicks} unique, "
|
|
24
|
+
f"{s.conversions} conversions (CR {s.conversion_rate:.2%})")
|
|
25
|
+
print(f"Revenue: {s.revenue_cents/100:.2f} Payout: {s.payout_cents/100:.2f}")
|
|
26
|
+
|
|
27
|
+
print("\nTop campaigns:")
|
|
28
|
+
for row in client.reports.breakdown(by="campaign")[:10]:
|
|
29
|
+
print(f" {row.label or row.key:<24} {row.clicks:>6} clicks {row.conversions:>4} conv")
|
|
30
|
+
except TrackOfferzError as e:
|
|
31
|
+
raise SystemExit(f"API error [{e.code}]: {e.message}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trackofferz"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the TrackOfferz reporting API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "SPTSPL", email = "support@trackofferz.com" }]
|
|
13
|
+
keywords = ["trackofferz", "affiliate", "tracking", "reporting", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
# Zero runtime dependencies — built on the Python standard library only.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://trackofferz.com"
|
|
25
|
+
Documentation = "https://trackofferz.com/docs"
|
|
26
|
+
Source = "https://github.com/trackofferz/trackofferz-python"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["trackofferz"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""TrackOfferz — official Python SDK for the reporting API.
|
|
2
|
+
|
|
3
|
+
from trackofferz import TrackOfferz
|
|
4
|
+
|
|
5
|
+
client = TrackOfferz(api_key="tofz_sk_…")
|
|
6
|
+
print(client.reports.summary(from_="2026-06-01", to="2026-06-30"))
|
|
7
|
+
|
|
8
|
+
Create a key in the panel under Settings → API keys. Every call is scoped to
|
|
9
|
+
your network. Docs: https://trackofferz.com/docs
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ._client import DEFAULT_BASE_URL, HTTPClient, __version__
|
|
14
|
+
from .errors import (
|
|
15
|
+
APIError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TrackOfferzError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
from .reports import Reports
|
|
22
|
+
from .types import (
|
|
23
|
+
BreakdownRow,
|
|
24
|
+
ClickRow,
|
|
25
|
+
ConversionRow,
|
|
26
|
+
DateRange,
|
|
27
|
+
Page,
|
|
28
|
+
Summary,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"TrackOfferz",
|
|
33
|
+
"Reports",
|
|
34
|
+
"TrackOfferzError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"APIError",
|
|
39
|
+
"Summary",
|
|
40
|
+
"ClickRow",
|
|
41
|
+
"ConversionRow",
|
|
42
|
+
"BreakdownRow",
|
|
43
|
+
"DateRange",
|
|
44
|
+
"Page",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TrackOfferz:
|
|
50
|
+
"""Top-level client. Holds the API key + transport; exposes namespaces.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
api_key: Network API key (``tofz_sk_…``) from Settings → API keys.
|
|
54
|
+
base_url: Override the API base (default production).
|
|
55
|
+
timeout: Per-request timeout in seconds.
|
|
56
|
+
max_retries: Retries for 429 / transient 5xx (exp backoff, honours
|
|
57
|
+
Retry-After).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: str,
|
|
63
|
+
*,
|
|
64
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
65
|
+
timeout: float = 30.0,
|
|
66
|
+
max_retries: int = 2,
|
|
67
|
+
):
|
|
68
|
+
self._http = HTTPClient(
|
|
69
|
+
api_key, base_url=base_url, timeout=timeout, max_retries=max_retries
|
|
70
|
+
)
|
|
71
|
+
self.reports = Reports(self._http)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Low-level HTTP transport. Standard library only (urllib) — no third-party
|
|
2
|
+
deps so the SDK adds nothing to your dependency tree.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import (
|
|
14
|
+
APIError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
TrackOfferzError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
DEFAULT_BASE_URL = "https://trackofferz.com/api/v1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HTTPClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str,
|
|
29
|
+
*,
|
|
30
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
max_retries: int = 2,
|
|
33
|
+
):
|
|
34
|
+
if not api_key or not api_key.startswith("tofz_"):
|
|
35
|
+
raise ValueError("api_key must be a TrackOfferz key (starts with 'tofz_').")
|
|
36
|
+
self._api_key = api_key
|
|
37
|
+
self._base_url = base_url.rstrip("/")
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
|
|
41
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
42
|
+
"""GET a path, returning the full parsed envelope ({data, meta}).
|
|
43
|
+
Retries 429 (honouring Retry-After) and transient 5xx with backoff."""
|
|
44
|
+
query = _encode_params(params or {})
|
|
45
|
+
url = f"{self._base_url}{path}"
|
|
46
|
+
if query:
|
|
47
|
+
url = f"{url}?{query}"
|
|
48
|
+
|
|
49
|
+
attempt = 0
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
return self._request(url)
|
|
53
|
+
except RateLimitError as e:
|
|
54
|
+
if attempt >= self._max_retries:
|
|
55
|
+
raise
|
|
56
|
+
_sleep(e.retry_after or _backoff(attempt))
|
|
57
|
+
except APIError as e:
|
|
58
|
+
# Retry transient 5xx only.
|
|
59
|
+
if (e.status or 0) < 500 or attempt >= self._max_retries:
|
|
60
|
+
raise
|
|
61
|
+
_sleep(_backoff(attempt))
|
|
62
|
+
attempt += 1
|
|
63
|
+
|
|
64
|
+
# ── internals ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def _request(self, url: str) -> Any:
|
|
67
|
+
req = urllib.request.Request(url, method="GET")
|
|
68
|
+
req.add_header("Authorization", f"Bearer {self._api_key}")
|
|
69
|
+
req.add_header("Accept", "application/json")
|
|
70
|
+
req.add_header("User-Agent", f"trackofferz-python/{__version__}")
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
73
|
+
body = resp.read().decode("utf-8")
|
|
74
|
+
return json.loads(body) if body else {}
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
self._raise_for_status(e)
|
|
77
|
+
except urllib.error.URLError as e:
|
|
78
|
+
raise TrackOfferzError(f"Network error: {e.reason}") from e
|
|
79
|
+
|
|
80
|
+
def _raise_for_status(self, e: "urllib.error.HTTPError") -> None:
|
|
81
|
+
status = e.code
|
|
82
|
+
code = "http_error"
|
|
83
|
+
message = e.reason or "request failed"
|
|
84
|
+
try:
|
|
85
|
+
err = json.loads(e.read().decode("utf-8")).get("error", {})
|
|
86
|
+
code = err.get("code", code)
|
|
87
|
+
message = err.get("message", message)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
if status == 401:
|
|
92
|
+
raise AuthenticationError(message, status=status, code=code)
|
|
93
|
+
if status == 422:
|
|
94
|
+
raise ValidationError(message, status=status, code=code)
|
|
95
|
+
if status == 429:
|
|
96
|
+
ra = e.headers.get("Retry-After") if e.headers else None
|
|
97
|
+
raise RateLimitError(
|
|
98
|
+
message,
|
|
99
|
+
status=status,
|
|
100
|
+
code=code,
|
|
101
|
+
retry_after=int(ra) if ra and ra.isdigit() else None,
|
|
102
|
+
)
|
|
103
|
+
raise APIError(message, status=status, code=code)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _encode_params(params: Dict[str, Any]) -> str:
|
|
107
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
108
|
+
# bools → lowercase strings the API expects ("true"/"false")
|
|
109
|
+
for k, v in list(clean.items()):
|
|
110
|
+
if isinstance(v, bool):
|
|
111
|
+
clean[k] = "true" if v else "false"
|
|
112
|
+
return urllib.parse.urlencode(clean)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _backoff(attempt: int) -> float:
|
|
116
|
+
return min(8.0, 0.5 * (2 ** attempt))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _sleep(seconds: float) -> None:
|
|
120
|
+
time.sleep(max(0.0, seconds))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Typed exceptions raised by the TrackOfferz client.
|
|
2
|
+
|
|
3
|
+
Catch :class:`TrackOfferzError` to handle any SDK error, or the specific
|
|
4
|
+
subclasses for finer control.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TrackOfferzError(Exception):
|
|
12
|
+
"""Base class for every error the SDK raises."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status = status
|
|
18
|
+
self.code = code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationError(TrackOfferzError):
|
|
22
|
+
"""401 — missing, invalid, or revoked API key."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(TrackOfferzError):
|
|
26
|
+
"""422 — invalid request parameters (bad dates, ids, etc.)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(TrackOfferzError):
|
|
30
|
+
"""429 — too many requests. ``retry_after`` is seconds to wait."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, *, retry_after: Optional[int] = None, **kw):
|
|
33
|
+
super().__init__(message, **kw)
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIError(TrackOfferzError):
|
|
38
|
+
"""Any other non-2xx response (incl. 5xx)."""
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The ``reports`` namespace: client.reports.summary(), .clicks(), etc."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Iterator, Optional
|
|
5
|
+
|
|
6
|
+
from ._client import HTTPClient
|
|
7
|
+
from .types import BreakdownRow, ClickRow, ConversionRow, Page, Summary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Reports:
|
|
11
|
+
def __init__(self, http: HTTPClient):
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def summary(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
from_: Optional[str] = None,
|
|
18
|
+
to: Optional[str] = None,
|
|
19
|
+
campaign_id: Optional[int] = None,
|
|
20
|
+
publisher_id: Optional[int] = None,
|
|
21
|
+
) -> Summary:
|
|
22
|
+
"""Aggregate totals over a date range (default: last 30 days)."""
|
|
23
|
+
payload = self._http.get(
|
|
24
|
+
"/reports/summary",
|
|
25
|
+
{"from": from_, "to": to, "campaign_id": campaign_id, "publisher_id": publisher_id},
|
|
26
|
+
)
|
|
27
|
+
return Summary._parse(payload.get("data", {}))
|
|
28
|
+
|
|
29
|
+
def clicks(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
from_: Optional[str] = None,
|
|
33
|
+
to: Optional[str] = None,
|
|
34
|
+
campaign_id: Optional[int] = None,
|
|
35
|
+
publisher_id: Optional[int] = None,
|
|
36
|
+
page: int = 1,
|
|
37
|
+
page_size: int = 100,
|
|
38
|
+
include_rejected: bool = False,
|
|
39
|
+
) -> Page:
|
|
40
|
+
"""One page of click detail (newest first)."""
|
|
41
|
+
payload = self._http.get(
|
|
42
|
+
"/reports/clicks",
|
|
43
|
+
{
|
|
44
|
+
"from": from_, "to": to,
|
|
45
|
+
"campaign_id": campaign_id, "publisher_id": publisher_id,
|
|
46
|
+
"page": page, "page_size": page_size,
|
|
47
|
+
"include_rejected": include_rejected,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
return _to_page(payload, ClickRow)
|
|
51
|
+
|
|
52
|
+
def conversions(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
from_: Optional[str] = None,
|
|
56
|
+
to: Optional[str] = None,
|
|
57
|
+
campaign_id: Optional[int] = None,
|
|
58
|
+
publisher_id: Optional[int] = None,
|
|
59
|
+
page: int = 1,
|
|
60
|
+
page_size: int = 100,
|
|
61
|
+
) -> Page:
|
|
62
|
+
"""One page of conversion detail (newest first, refunds excluded)."""
|
|
63
|
+
payload = self._http.get(
|
|
64
|
+
"/reports/conversions",
|
|
65
|
+
{
|
|
66
|
+
"from": from_, "to": to,
|
|
67
|
+
"campaign_id": campaign_id, "publisher_id": publisher_id,
|
|
68
|
+
"page": page, "page_size": page_size,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
return _to_page(payload, ConversionRow)
|
|
72
|
+
|
|
73
|
+
def breakdown(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
by: str = "campaign",
|
|
77
|
+
from_: Optional[str] = None,
|
|
78
|
+
to: Optional[str] = None,
|
|
79
|
+
) -> list:
|
|
80
|
+
"""Performance grouped by 'campaign' | 'publisher' | 'country' | 'sub_id'."""
|
|
81
|
+
payload = self._http.get("/reports/breakdown", {"by": by, "from": from_, "to": to})
|
|
82
|
+
return [BreakdownRow._parse(r) for r in payload.get("data", [])]
|
|
83
|
+
|
|
84
|
+
# ── auto-paginating iterators ────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def iter_clicks(self, *, page_size: int = 1000, **kw) -> Iterator[ClickRow]:
|
|
87
|
+
"""Yield every click across all pages (handles pagination for you)."""
|
|
88
|
+
page = 1
|
|
89
|
+
while True:
|
|
90
|
+
p = self.clicks(page=page, page_size=page_size, **kw)
|
|
91
|
+
for row in p.rows:
|
|
92
|
+
yield row
|
|
93
|
+
if not p.has_next:
|
|
94
|
+
break
|
|
95
|
+
page += 1
|
|
96
|
+
|
|
97
|
+
def iter_conversions(self, *, page_size: int = 1000, **kw) -> Iterator[ConversionRow]:
|
|
98
|
+
"""Yield every conversion across all pages."""
|
|
99
|
+
page = 1
|
|
100
|
+
while True:
|
|
101
|
+
p = self.conversions(page=page, page_size=page_size, **kw)
|
|
102
|
+
for row in p.rows:
|
|
103
|
+
yield row
|
|
104
|
+
if not p.has_next:
|
|
105
|
+
break
|
|
106
|
+
page += 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _to_page(payload: dict, row_cls) -> Page:
|
|
110
|
+
rows = [row_cls._parse(r) for r in payload.get("data", [])]
|
|
111
|
+
meta = payload.get("meta", {}) or {}
|
|
112
|
+
return Page(
|
|
113
|
+
rows=rows,
|
|
114
|
+
page=int(meta.get("page", 1)),
|
|
115
|
+
page_size=int(meta.get("page_size", len(rows))),
|
|
116
|
+
total=int(meta.get("total", len(rows))),
|
|
117
|
+
total_pages=int(meta.get("total_pages", 1)),
|
|
118
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Typed result objects. Each parses straight from the API JSON.
|
|
2
|
+
|
|
3
|
+
Money fields are integers in CENTS (e.g. payout_cents=1250 → $12.50), matching
|
|
4
|
+
the API. Convert to your display currency at the edge.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class DateRange:
|
|
14
|
+
from_: str
|
|
15
|
+
to: str
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def _parse(cls, d: Dict[str, Any]) -> "DateRange":
|
|
19
|
+
return cls(from_=d.get("from", ""), to=d.get("to", ""))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Summary:
|
|
24
|
+
range: DateRange
|
|
25
|
+
clicks: int
|
|
26
|
+
unique_clicks: int
|
|
27
|
+
conversions: int
|
|
28
|
+
conversion_rate: float
|
|
29
|
+
payout_cents: int
|
|
30
|
+
revenue_cents: int
|
|
31
|
+
sale_amount_cents: int
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _parse(cls, d: Dict[str, Any]) -> "Summary":
|
|
35
|
+
return cls(
|
|
36
|
+
range=DateRange._parse(d.get("range", {})),
|
|
37
|
+
clicks=int(d.get("clicks", 0)),
|
|
38
|
+
unique_clicks=int(d.get("unique_clicks", 0)),
|
|
39
|
+
conversions=int(d.get("conversions", 0)),
|
|
40
|
+
conversion_rate=float(d.get("conversion_rate", 0.0)),
|
|
41
|
+
payout_cents=int(d.get("payout_cents", 0)),
|
|
42
|
+
revenue_cents=int(d.get("revenue_cents", 0)),
|
|
43
|
+
sale_amount_cents=int(d.get("sale_amount_cents", 0)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ClickRow:
|
|
49
|
+
event_time: str
|
|
50
|
+
transaction_id: str
|
|
51
|
+
campaign_id: int
|
|
52
|
+
publisher_id: int
|
|
53
|
+
country_code: str
|
|
54
|
+
region: str
|
|
55
|
+
city: str
|
|
56
|
+
os: str
|
|
57
|
+
browser: str
|
|
58
|
+
device_type: str
|
|
59
|
+
ip_address: str
|
|
60
|
+
asn: int
|
|
61
|
+
isp: str
|
|
62
|
+
sub_ids: List[str] = field(default_factory=list)
|
|
63
|
+
is_unique: bool = True
|
|
64
|
+
fraud_score: int = 0
|
|
65
|
+
rejected: bool = False
|
|
66
|
+
rejection_code: str = ""
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def _parse(cls, d: Dict[str, Any]) -> "ClickRow":
|
|
70
|
+
return cls(
|
|
71
|
+
event_time=str(d.get("event_time", "")),
|
|
72
|
+
transaction_id=str(d.get("transaction_id", "")),
|
|
73
|
+
campaign_id=int(d.get("campaign_id", 0)),
|
|
74
|
+
publisher_id=int(d.get("publisher_id", 0)),
|
|
75
|
+
country_code=str(d.get("country_code", "")),
|
|
76
|
+
region=str(d.get("region", "")),
|
|
77
|
+
city=str(d.get("city", "")),
|
|
78
|
+
os=str(d.get("os", "")),
|
|
79
|
+
browser=str(d.get("browser", "")),
|
|
80
|
+
device_type=str(d.get("device_type", "")),
|
|
81
|
+
ip_address=str(d.get("ip_address", "")),
|
|
82
|
+
asn=int(d.get("asn", 0)),
|
|
83
|
+
isp=str(d.get("isp", "")),
|
|
84
|
+
sub_ids=list(d.get("sub_ids", []) or []),
|
|
85
|
+
is_unique=bool(d.get("is_unique", True)),
|
|
86
|
+
fraud_score=int(d.get("fraud_score", 0)),
|
|
87
|
+
rejected=bool(d.get("rejected", False)),
|
|
88
|
+
rejection_code=str(d.get("rejection_code", "")),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ConversionRow:
|
|
94
|
+
event_time: str
|
|
95
|
+
transaction_id: str
|
|
96
|
+
campaign_id: int
|
|
97
|
+
publisher_id: int
|
|
98
|
+
event_type: str
|
|
99
|
+
status: str
|
|
100
|
+
payout_cents: int
|
|
101
|
+
sale_amount_cents: int
|
|
102
|
+
advertiser_cost_cents: int
|
|
103
|
+
currency: str
|
|
104
|
+
country_code: str
|
|
105
|
+
ctit_seconds: int
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def _parse(cls, d: Dict[str, Any]) -> "ConversionRow":
|
|
109
|
+
return cls(
|
|
110
|
+
event_time=str(d.get("event_time", "")),
|
|
111
|
+
transaction_id=str(d.get("transaction_id", "")),
|
|
112
|
+
campaign_id=int(d.get("campaign_id", 0)),
|
|
113
|
+
publisher_id=int(d.get("publisher_id", 0)),
|
|
114
|
+
event_type=str(d.get("event_type", "")),
|
|
115
|
+
status=str(d.get("status", "")),
|
|
116
|
+
payout_cents=int(d.get("payout_cents", 0)),
|
|
117
|
+
sale_amount_cents=int(d.get("sale_amount_cents", 0)),
|
|
118
|
+
advertiser_cost_cents=int(d.get("advertiser_cost_cents", 0)),
|
|
119
|
+
currency=str(d.get("currency", "")),
|
|
120
|
+
country_code=str(d.get("country_code", "")),
|
|
121
|
+
ctit_seconds=int(d.get("ctit_seconds", 0)),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class BreakdownRow:
|
|
127
|
+
key: str
|
|
128
|
+
label: Optional[str]
|
|
129
|
+
clicks: int
|
|
130
|
+
unique_clicks: int
|
|
131
|
+
conversions: int
|
|
132
|
+
payout_cents: int
|
|
133
|
+
revenue_cents: int
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def _parse(cls, d: Dict[str, Any]) -> "BreakdownRow":
|
|
137
|
+
return cls(
|
|
138
|
+
key=str(d.get("key", "")),
|
|
139
|
+
label=d.get("label"),
|
|
140
|
+
clicks=int(d.get("clicks", 0)),
|
|
141
|
+
unique_clicks=int(d.get("unique_clicks", 0)),
|
|
142
|
+
conversions=int(d.get("conversions", 0)),
|
|
143
|
+
payout_cents=int(d.get("payout_cents", 0)),
|
|
144
|
+
revenue_cents=int(d.get("revenue_cents", 0)),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Page:
|
|
150
|
+
"""One page of detail rows + pagination cursor."""
|
|
151
|
+
|
|
152
|
+
rows: List[Any]
|
|
153
|
+
page: int
|
|
154
|
+
page_size: int
|
|
155
|
+
total: int
|
|
156
|
+
total_pages: int
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def has_next(self) -> bool:
|
|
160
|
+
return self.page < self.total_pages
|