tapcub 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.
- tapcub-0.1.0/LICENSE +21 -0
- tapcub-0.1.0/PKG-INFO +164 -0
- tapcub-0.1.0/README.md +133 -0
- tapcub-0.1.0/pyproject.toml +44 -0
- tapcub-0.1.0/setup.cfg +4 -0
- tapcub-0.1.0/tapcub/__init__.py +12 -0
- tapcub-0.1.0/tapcub/_normalize.py +254 -0
- tapcub-0.1.0/tapcub/client.py +394 -0
- tapcub-0.1.0/tapcub/py.typed +0 -0
- tapcub-0.1.0/tapcub/version.py +2 -0
- tapcub-0.1.0/tapcub.egg-info/PKG-INFO +164 -0
- tapcub-0.1.0/tapcub.egg-info/SOURCES.txt +14 -0
- tapcub-0.1.0/tapcub.egg-info/dependency_links.txt +1 -0
- tapcub-0.1.0/tapcub.egg-info/top_level.txt +1 -0
- tapcub-0.1.0/tests/test_client.py +222 -0
- tapcub-0.1.0/tests/test_vectors.py +89 -0
tapcub-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TapCub
|
|
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.
|
tapcub-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tapcub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TapCub server-side SDK for Python: events, sign-ups, identities, user profiles, items and accounts
|
|
5
|
+
Author-email: TapCub <hello@tapcub.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tapcub.com
|
|
8
|
+
Project-URL: Documentation, https://docs.tapcub.com/en/sdk/server#python
|
|
9
|
+
Project-URL: Source, https://github.com/tapcub/tapcub-sdk/tree/main/packages/sdk-server/python
|
|
10
|
+
Project-URL: Issues, https://github.com/tapcub/tapcub-sdk/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/tapcub/tapcub-sdk/blob/main/packages/sdk-server/python/CHANGELOG.md
|
|
12
|
+
Keywords: tapcub,analytics,server-side,events,tracking,product-analytics
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# tapcub
|
|
33
|
+
|
|
34
|
+
Server-side SDK for [TapCub](https://tapcub.com) on Python: business events, sign-ups, identities, user profiles, items and accounts, sent to `POST /api/v1/track`. Standard library only, typed (`py.typed`).
|
|
35
|
+
|
|
36
|
+
- Batches in memory and sends from a background thread (or sends on every call)
|
|
37
|
+
- Retries network errors, 5xx and 429 with exponential backoff, without changing the batch
|
|
38
|
+
- Cleans each event before it is queued, so one bad value never rejects a whole batch
|
|
39
|
+
- Hashes email and mobile identities (SHA-256) before they leave your server
|
|
40
|
+
|
|
41
|
+
Requires Python 3.8 or newer.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install tapcub
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import os
|
|
53
|
+
from tapcub import TapCub
|
|
54
|
+
|
|
55
|
+
tc = TapCub(os.environ["TAPCUB_API_KEY"]) # a wck_… key with the track:write scope
|
|
56
|
+
|
|
57
|
+
tc.track(
|
|
58
|
+
"purchase",
|
|
59
|
+
login_id=user.id,
|
|
60
|
+
insert_id=order.id, # idempotency key: retries of the same order are counted once
|
|
61
|
+
properties={"revenue": 99, "currency": "USD", "items": [{"item_id": "sku-1", "price": 99, "quantity": 1}]},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
tc.close() # also runs automatically when the interpreter exits
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Create the key in the TapCub console under **Configuration › Settings › API keys** with the **Server track** scope. It is shown once; keep it on the server.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
`TapCub(api_key, endpoint=None, *, …)`
|
|
72
|
+
|
|
73
|
+
| Argument | Default | Description |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `api_key` | — | Required. Project key with the `track:write` (Server track) scope |
|
|
76
|
+
| `endpoint` | built in | Leave unset. Set it only when TapCub gives you a dedicated address (private deployment). A trailing `/api/v1/track` is removed |
|
|
77
|
+
| `consumer` | `"batch"` | `"batch"`: queue and send from a daemon thread. `"sync"`: send on every call and raise `TapCubError` on failure (calls return the server response). `"debug"`: like `"sync"` against `?debug=1` (validated, nothing stored), every result is logged |
|
|
78
|
+
| `max_batch` | `200` | Events per request (at most 1000). Requests are also kept under 1 MB |
|
|
79
|
+
| `flush_interval` | `3.0` | Seconds between sends |
|
|
80
|
+
| `max_queue_size` | `10000` | Queued events kept in memory; the oldest are dropped beyond this |
|
|
81
|
+
| `max_retries` | `5` | Retries for network errors, 5xx and 429 (honours `Retry-After`) |
|
|
82
|
+
| `timeout` | `10.0` | Seconds per request |
|
|
83
|
+
| `gzip` | `False` | Send `Content-Encoding: gzip` |
|
|
84
|
+
| `on_error` | log to the `tapcub` logger | Called with a `TapCubError` for every background failure |
|
|
85
|
+
| `logger` | `logging.getLogger("tapcub")` | |
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
Invalid arguments raise `ValueError` and queue nothing. The client is thread-safe; create one per process.
|
|
90
|
+
|
|
91
|
+
| Method | Event |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `track(event, *, login_id=None, login_id_type=None, device_id=None, session_id=None, insert_id=None, time=None, group=None, properties=None, context=None)` | A business event |
|
|
94
|
+
| `track_signup(login_id, *, device_id=None, invited_by=None, properties=None, context=None, …)` | `sign_up`; links the anonymous device to the new login ID |
|
|
95
|
+
| `bind(login_id, {"email": …, "mobile": …, "unionid": …})` | Attach more identities (email / mobile hashed in the SDK) |
|
|
96
|
+
| `unbind(login_id, identity_type, value)` | Remove one identity |
|
|
97
|
+
| `profile_set(login_id, props)` / `profile_set_once(login_id, props)` | Set profile properties (`$name`, `$email`, `$phone`, `$avatar` are display fields) |
|
|
98
|
+
| `profile_increment(login_id, {key: delta})` / `profile_append(login_id, {key: [values]})` / `profile_unset(login_id, [keys])` | Change profile properties |
|
|
99
|
+
| `item_set(item_type, item_id, props)` / `item_delete(item_type, item_id)` | Dimension rows (products, articles, stores) |
|
|
100
|
+
| `track_update(event, insert_id, props)` / `track_overwrite(event, insert_id, props)` | Merge into / replace the properties of a stored event |
|
|
101
|
+
| `group_set(group_type, group_id, props)` | Account (B2B group) traits; attach events with `track(…, group="company:acme")` |
|
|
102
|
+
| `flush()` | Send the queue now; raises the first `TapCubError` |
|
|
103
|
+
| `close()` | Stop the thread and `flush()`; `with TapCub(…) as tc:` does this for you |
|
|
104
|
+
| `from_request_headers(headers)` | `{"device_id", "session_id"}` from `X-Tc-Device` / `X-Tc-Session` (Django, Flask, Starlette, WSGI environ) |
|
|
105
|
+
|
|
106
|
+
`time` is milliseconds since the epoch or a `datetime` and defaults to the time of the call. Properties are flat: strings (cut to 1000 characters), numbers and booleans; dates become ISO-8601 strings; a list of dicts such as `items` is kept one level deep; other nested values are sent as JSON strings.
|
|
107
|
+
|
|
108
|
+
### Record where a sign-up came from
|
|
109
|
+
|
|
110
|
+
The browser script already knows the visit source (`tapcub.getAttribution()`). Send it to your backend with the sign-up form and pass it through unchanged:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Django view
|
|
114
|
+
def signup(request):
|
|
115
|
+
data = json.loads(request.body)
|
|
116
|
+
user = create_user(data)
|
|
117
|
+
tc.track_signup(
|
|
118
|
+
user.id,
|
|
119
|
+
device_id=data.get("tapcubDeviceId"), # optional: links the anonymous visits
|
|
120
|
+
invited_by=data.get("inviterId"), # optional: referral program → properties.$invited_by
|
|
121
|
+
context={
|
|
122
|
+
"campaign": data.get("attribution"), # {source, medium, name, term, content, ref, click_id, landing_url, referrer, touched_at}
|
|
123
|
+
"ip": request.META.get("REMOTE_ADDR"), # ip + user_agent let TapCub infer the source when there is no cookie
|
|
124
|
+
"user_agent": request.headers.get("User-Agent"),
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
return JsonResponse({"ok": True})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Idempotency
|
|
131
|
+
|
|
132
|
+
The SDK resends a failed batch unchanged, but it does **not** generate `insert_id` for you. Pass a natural key (order ID, payment ID, ≤36 characters) for events that must not be counted twice; the server stores one event per `insert_id`.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from tapcub import TapCub, TapCubError
|
|
138
|
+
|
|
139
|
+
def report(err: TapCubError) -> None:
|
|
140
|
+
log.warning("tapcub %s (%s): %d events lost", err.code, err.status, len(err.events))
|
|
141
|
+
|
|
142
|
+
tc = TapCub(api_key, on_error=report)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`err.code` is the server code (`NO_API_KEY`, `BAD_API_KEY`, `VALIDATION`, …), `HTTP_<status>`, `NETWORK`, `EVENT_REJECTED` (the request succeeded but some events were rejected; see `err.results`) or `QUEUE_FULL`. 4xx responses other than 429 are not retried.
|
|
146
|
+
|
|
147
|
+
## Short-lived processes and workers
|
|
148
|
+
|
|
149
|
+
Scripts, Lambda handlers and Celery tasks should call `tc.flush()` before returning, or use `consumer="sync"`. With forking servers (gunicorn `--preload`, uWSGI) create the client after the fork, because the sender thread does not survive it.
|
|
150
|
+
|
|
151
|
+
## Check your integration
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
TapCub(api_key, consumer="debug").track("purchase", login_id="u1", properties={"revenue": 1})
|
|
155
|
+
# logs "debug #0 ok" (or the warnings) and stores nothing
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Documentation
|
|
159
|
+
|
|
160
|
+
[docs.tapcub.com → Server SDKs](https://docs.tapcub.com/en/sdk/server#python). Behaviour shared by every TapCub server SDK is specified by the conformance vectors in [`packages/shared/test-vectors/track`](https://github.com/tapcub/tapcub-sdk/tree/main/packages/shared/test-vectors/track).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
tapcub-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# tapcub
|
|
2
|
+
|
|
3
|
+
Server-side SDK for [TapCub](https://tapcub.com) on Python: business events, sign-ups, identities, user profiles, items and accounts, sent to `POST /api/v1/track`. Standard library only, typed (`py.typed`).
|
|
4
|
+
|
|
5
|
+
- Batches in memory and sends from a background thread (or sends on every call)
|
|
6
|
+
- Retries network errors, 5xx and 429 with exponential backoff, without changing the batch
|
|
7
|
+
- Cleans each event before it is queued, so one bad value never rejects a whole batch
|
|
8
|
+
- Hashes email and mobile identities (SHA-256) before they leave your server
|
|
9
|
+
|
|
10
|
+
Requires Python 3.8 or newer.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tapcub
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from tapcub import TapCub
|
|
23
|
+
|
|
24
|
+
tc = TapCub(os.environ["TAPCUB_API_KEY"]) # a wck_… key with the track:write scope
|
|
25
|
+
|
|
26
|
+
tc.track(
|
|
27
|
+
"purchase",
|
|
28
|
+
login_id=user.id,
|
|
29
|
+
insert_id=order.id, # idempotency key: retries of the same order are counted once
|
|
30
|
+
properties={"revenue": 99, "currency": "USD", "items": [{"item_id": "sku-1", "price": 99, "quantity": 1}]},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
tc.close() # also runs automatically when the interpreter exits
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Create the key in the TapCub console under **Configuration › Settings › API keys** with the **Server track** scope. It is shown once; keep it on the server.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
`TapCub(api_key, endpoint=None, *, …)`
|
|
41
|
+
|
|
42
|
+
| Argument | Default | Description |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `api_key` | — | Required. Project key with the `track:write` (Server track) scope |
|
|
45
|
+
| `endpoint` | built in | Leave unset. Set it only when TapCub gives you a dedicated address (private deployment). A trailing `/api/v1/track` is removed |
|
|
46
|
+
| `consumer` | `"batch"` | `"batch"`: queue and send from a daemon thread. `"sync"`: send on every call and raise `TapCubError` on failure (calls return the server response). `"debug"`: like `"sync"` against `?debug=1` (validated, nothing stored), every result is logged |
|
|
47
|
+
| `max_batch` | `200` | Events per request (at most 1000). Requests are also kept under 1 MB |
|
|
48
|
+
| `flush_interval` | `3.0` | Seconds between sends |
|
|
49
|
+
| `max_queue_size` | `10000` | Queued events kept in memory; the oldest are dropped beyond this |
|
|
50
|
+
| `max_retries` | `5` | Retries for network errors, 5xx and 429 (honours `Retry-After`) |
|
|
51
|
+
| `timeout` | `10.0` | Seconds per request |
|
|
52
|
+
| `gzip` | `False` | Send `Content-Encoding: gzip` |
|
|
53
|
+
| `on_error` | log to the `tapcub` logger | Called with a `TapCubError` for every background failure |
|
|
54
|
+
| `logger` | `logging.getLogger("tapcub")` | |
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
Invalid arguments raise `ValueError` and queue nothing. The client is thread-safe; create one per process.
|
|
59
|
+
|
|
60
|
+
| Method | Event |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `track(event, *, login_id=None, login_id_type=None, device_id=None, session_id=None, insert_id=None, time=None, group=None, properties=None, context=None)` | A business event |
|
|
63
|
+
| `track_signup(login_id, *, device_id=None, invited_by=None, properties=None, context=None, …)` | `sign_up`; links the anonymous device to the new login ID |
|
|
64
|
+
| `bind(login_id, {"email": …, "mobile": …, "unionid": …})` | Attach more identities (email / mobile hashed in the SDK) |
|
|
65
|
+
| `unbind(login_id, identity_type, value)` | Remove one identity |
|
|
66
|
+
| `profile_set(login_id, props)` / `profile_set_once(login_id, props)` | Set profile properties (`$name`, `$email`, `$phone`, `$avatar` are display fields) |
|
|
67
|
+
| `profile_increment(login_id, {key: delta})` / `profile_append(login_id, {key: [values]})` / `profile_unset(login_id, [keys])` | Change profile properties |
|
|
68
|
+
| `item_set(item_type, item_id, props)` / `item_delete(item_type, item_id)` | Dimension rows (products, articles, stores) |
|
|
69
|
+
| `track_update(event, insert_id, props)` / `track_overwrite(event, insert_id, props)` | Merge into / replace the properties of a stored event |
|
|
70
|
+
| `group_set(group_type, group_id, props)` | Account (B2B group) traits; attach events with `track(…, group="company:acme")` |
|
|
71
|
+
| `flush()` | Send the queue now; raises the first `TapCubError` |
|
|
72
|
+
| `close()` | Stop the thread and `flush()`; `with TapCub(…) as tc:` does this for you |
|
|
73
|
+
| `from_request_headers(headers)` | `{"device_id", "session_id"}` from `X-Tc-Device` / `X-Tc-Session` (Django, Flask, Starlette, WSGI environ) |
|
|
74
|
+
|
|
75
|
+
`time` is milliseconds since the epoch or a `datetime` and defaults to the time of the call. Properties are flat: strings (cut to 1000 characters), numbers and booleans; dates become ISO-8601 strings; a list of dicts such as `items` is kept one level deep; other nested values are sent as JSON strings.
|
|
76
|
+
|
|
77
|
+
### Record where a sign-up came from
|
|
78
|
+
|
|
79
|
+
The browser script already knows the visit source (`tapcub.getAttribution()`). Send it to your backend with the sign-up form and pass it through unchanged:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# Django view
|
|
83
|
+
def signup(request):
|
|
84
|
+
data = json.loads(request.body)
|
|
85
|
+
user = create_user(data)
|
|
86
|
+
tc.track_signup(
|
|
87
|
+
user.id,
|
|
88
|
+
device_id=data.get("tapcubDeviceId"), # optional: links the anonymous visits
|
|
89
|
+
invited_by=data.get("inviterId"), # optional: referral program → properties.$invited_by
|
|
90
|
+
context={
|
|
91
|
+
"campaign": data.get("attribution"), # {source, medium, name, term, content, ref, click_id, landing_url, referrer, touched_at}
|
|
92
|
+
"ip": request.META.get("REMOTE_ADDR"), # ip + user_agent let TapCub infer the source when there is no cookie
|
|
93
|
+
"user_agent": request.headers.get("User-Agent"),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
return JsonResponse({"ok": True})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Idempotency
|
|
100
|
+
|
|
101
|
+
The SDK resends a failed batch unchanged, but it does **not** generate `insert_id` for you. Pass a natural key (order ID, payment ID, ≤36 characters) for events that must not be counted twice; the server stores one event per `insert_id`.
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from tapcub import TapCub, TapCubError
|
|
107
|
+
|
|
108
|
+
def report(err: TapCubError) -> None:
|
|
109
|
+
log.warning("tapcub %s (%s): %d events lost", err.code, err.status, len(err.events))
|
|
110
|
+
|
|
111
|
+
tc = TapCub(api_key, on_error=report)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`err.code` is the server code (`NO_API_KEY`, `BAD_API_KEY`, `VALIDATION`, …), `HTTP_<status>`, `NETWORK`, `EVENT_REJECTED` (the request succeeded but some events were rejected; see `err.results`) or `QUEUE_FULL`. 4xx responses other than 429 are not retried.
|
|
115
|
+
|
|
116
|
+
## Short-lived processes and workers
|
|
117
|
+
|
|
118
|
+
Scripts, Lambda handlers and Celery tasks should call `tc.flush()` before returning, or use `consumer="sync"`. With forking servers (gunicorn `--preload`, uWSGI) create the client after the fork, because the sender thread does not survive it.
|
|
119
|
+
|
|
120
|
+
## Check your integration
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
TapCub(api_key, consumer="debug").track("purchase", login_id="u1", properties={"revenue": 1})
|
|
124
|
+
# logs "debug #0 ok" (or the warnings) and stores nothing
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
|
|
129
|
+
[docs.tapcub.com → Server SDKs](https://docs.tapcub.com/en/sdk/server#python). Behaviour shared by every TapCub server SDK is specified by the conformance vectors in [`packages/shared/test-vectors/track`](https://github.com/tapcub/tapcub-sdk/tree/main/packages/shared/test-vectors/track).
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tapcub"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "TapCub server-side SDK for Python: events, sign-ups, identities, user profiles, items and accounts"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "TapCub", email = "hello@tapcub.com" }]
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["tapcub", "analytics", "server-side", "events", "tracking", "product-analytics"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://tapcub.com"
|
|
35
|
+
Documentation = "https://docs.tapcub.com/en/sdk/server#python"
|
|
36
|
+
Source = "https://github.com/tapcub/tapcub-sdk/tree/main/packages/sdk-server/python"
|
|
37
|
+
Issues = "https://github.com/tapcub/tapcub-sdk/issues"
|
|
38
|
+
Changelog = "https://github.com/tapcub/tapcub-sdk/blob/main/packages/sdk-server/python/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
packages = ["tapcub"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
tapcub = ["py.typed"]
|
tapcub-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""TapCub server-side SDK for Python: events, sign-ups, identities, user profiles, items and accounts.
|
|
2
|
+
|
|
3
|
+
from tapcub import TapCub
|
|
4
|
+
tc = TapCub("wck_…")
|
|
5
|
+
tc.track("purchase", login_id="user-1", insert_id="order-1", properties={"revenue": 19.9})
|
|
6
|
+
tc.close()
|
|
7
|
+
"""
|
|
8
|
+
from .client import DEFAULT_ENDPOINT, TapCub, TapCubError, from_request_headers, normalize_endpoint
|
|
9
|
+
from .version import VERSION
|
|
10
|
+
|
|
11
|
+
__version__ = VERSION
|
|
12
|
+
__all__ = ["TapCub", "TapCubError", "from_request_headers", "normalize_endpoint", "DEFAULT_ENDPOINT", "VERSION", "__version__"]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Event normalization shared by every TapCub server SDK.
|
|
2
|
+
|
|
3
|
+
Rules: packages/shared/test-vectors/track/README.md (same limits as the server's request schema).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as _dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import time as _time
|
|
13
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
LOGIN_ID_TYPES = ("login", "email", "mobile", "unionid", "openid", "custom")
|
|
16
|
+
PLATFORMS = (
|
|
17
|
+
"web", "mp_weixin", "mp_alipay", "mp_bytedance", "mp_baidu", "mp_kuaishou", "mp_qq",
|
|
18
|
+
"android", "ios", "harmony", "tvos", "watchos", "macos", "windows", "linux", "server", "import",
|
|
19
|
+
)
|
|
20
|
+
_GROUP_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,23}:[A-Za-z0-9_@.\-]{1,80}$")
|
|
21
|
+
_GROUP_TYPE_RE = re.compile(r"^[a-z][a-z0-9_]{0,23}$")
|
|
22
|
+
_HEX64 = re.compile(r"^[a-fA-F0-9]{64}$")
|
|
23
|
+
_COUNTRY = re.compile(r"^[A-Za-z]{2}$")
|
|
24
|
+
_CAMPAIGN_LIMITS = (
|
|
25
|
+
("source", 100), ("medium", 100), ("name", 100), ("term", 100), ("content", 100),
|
|
26
|
+
("ref", 64), ("click_id", 1000), ("landing_url", 2000), ("referrer", 2000),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256(s: str) -> str:
|
|
31
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def utf16_len(s: str) -> int:
|
|
35
|
+
"""服务端 zod 的 max 按 JS .length(UTF-16 码元)计"""
|
|
36
|
+
return len(s) + sum(1 for ch in s if ord(ch) > 0xFFFF)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cut(s: str, n: int) -> str:
|
|
40
|
+
"""截到 ≤n 个 UTF-16 码元,不拆开代理对"""
|
|
41
|
+
if len(s) <= n // 2 or utf16_len(s) <= n:
|
|
42
|
+
return s
|
|
43
|
+
units = 0
|
|
44
|
+
for i, ch in enumerate(s):
|
|
45
|
+
units += 2 if ord(ch) > 0xFFFF else 1
|
|
46
|
+
if units > n:
|
|
47
|
+
return s[:i]
|
|
48
|
+
return s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text(v: Any) -> str:
|
|
52
|
+
return v if isinstance(v, str) else str(v)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_text(name: str, v: Any, max_len: Optional[int] = None) -> str:
|
|
56
|
+
s = _text(v).strip() if v is not None else ""
|
|
57
|
+
if not s:
|
|
58
|
+
raise ValueError(f"TapCub: {name} is required")
|
|
59
|
+
if max_len is not None and utf16_len(s) > max_len:
|
|
60
|
+
raise ValueError(f"TapCub: {name} must be at most {max_len} characters")
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def optional_text(name: str, v: Any, max_len: int) -> Optional[str]:
|
|
65
|
+
if v is None:
|
|
66
|
+
return None
|
|
67
|
+
s = _text(v).strip()
|
|
68
|
+
if not s:
|
|
69
|
+
return None
|
|
70
|
+
if utf16_len(s) > max_len:
|
|
71
|
+
raise ValueError(f"TapCub: {name} must be at most {max_len} characters")
|
|
72
|
+
return s
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def event_name(v: Any) -> str:
|
|
76
|
+
s = _text(v).strip() if v is not None else ""
|
|
77
|
+
if not s:
|
|
78
|
+
raise ValueError("TapCub: event is required")
|
|
79
|
+
return cut(s, 100)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def login_id_type(v: Any) -> Optional[str]:
|
|
83
|
+
if v is None:
|
|
84
|
+
return None
|
|
85
|
+
if v not in LOGIN_ID_TYPES:
|
|
86
|
+
raise ValueError(f"TapCub: login_id_type must be one of {', '.join(LOGIN_ID_TYPES)}")
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def identity_value(kind: str, value: Any) -> str:
|
|
91
|
+
v = _text(value).strip() if value is not None else ""
|
|
92
|
+
if kind in ("email", "mobile"):
|
|
93
|
+
n = v.lower() if kind == "email" else re.sub(r"[\s-]", "", v)
|
|
94
|
+
return n.lower() if _HEX64.match(n) else sha256(n)
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def identities(v: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
99
|
+
out: Dict[str, str] = {}
|
|
100
|
+
for k, raw in (v or {}).items():
|
|
101
|
+
if k not in LOGIN_ID_TYPES:
|
|
102
|
+
raise ValueError(f'TapCub: unknown identity type "{k}"')
|
|
103
|
+
if raw is None or _text(raw).strip() == "":
|
|
104
|
+
continue
|
|
105
|
+
out[k] = identity_value(k, raw)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def group(v: Any) -> Optional[str]:
|
|
110
|
+
if v is None:
|
|
111
|
+
return None
|
|
112
|
+
s = _text(v)
|
|
113
|
+
if not _GROUP_KEY_RE.match(s):
|
|
114
|
+
raise ValueError('TapCub: group must look like "type:id" (type: a-z, 0-9, _)')
|
|
115
|
+
return s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def group_key(kind: Any, gid: Any) -> str:
|
|
119
|
+
t = require_text("group_type", kind)
|
|
120
|
+
if not _GROUP_TYPE_RE.match(t):
|
|
121
|
+
raise ValueError("TapCub: group_type must start with a-z and contain only a-z, 0-9 and _ (at most 24)")
|
|
122
|
+
return group(f"{t}:{require_text('group_id', gid)}") # type: ignore[return-value]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def timestamp(v: Any) -> int:
|
|
126
|
+
"""毫秒时间戳;datetime 按自身时区(naive 视为本地时间,同 datetime.timestamp())"""
|
|
127
|
+
if v is None:
|
|
128
|
+
return int(_time.time() * 1000)
|
|
129
|
+
if isinstance(v, _dt.datetime):
|
|
130
|
+
return int(v.timestamp() * 1000)
|
|
131
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or (isinstance(v, float) and not math.isfinite(v)):
|
|
132
|
+
raise ValueError("TapCub: time must be milliseconds since the epoch or a datetime")
|
|
133
|
+
return int(v)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _json_text(v: Any) -> str:
|
|
137
|
+
return json.dumps(v, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _json_default(o: Any) -> Any:
|
|
141
|
+
if isinstance(o, (_dt.datetime, _dt.date)):
|
|
142
|
+
return o.isoformat()
|
|
143
|
+
if isinstance(o, (set, frozenset, tuple)):
|
|
144
|
+
return list(o)
|
|
145
|
+
return str(o)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _scalar(v: Any) -> Any:
|
|
149
|
+
"""返回 None 表示丢弃"""
|
|
150
|
+
if v is None:
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(v, bool):
|
|
153
|
+
return v
|
|
154
|
+
if isinstance(v, int):
|
|
155
|
+
return v
|
|
156
|
+
if isinstance(v, float):
|
|
157
|
+
return v if math.isfinite(v) else None
|
|
158
|
+
if isinstance(v, str):
|
|
159
|
+
return cut(v, 1000)
|
|
160
|
+
if isinstance(v, (_dt.datetime, _dt.date)):
|
|
161
|
+
return v.isoformat()
|
|
162
|
+
if isinstance(v, (Mapping, list, tuple, set, frozenset)):
|
|
163
|
+
return cut(_json_text(v), 1000)
|
|
164
|
+
return cut(str(v), 1000)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def properties(p: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
168
|
+
if not p:
|
|
169
|
+
return None
|
|
170
|
+
out: Dict[str, Any] = {}
|
|
171
|
+
for k, v in p.items():
|
|
172
|
+
key = cut(_text(k), 50)
|
|
173
|
+
if isinstance(v, (list, tuple)) and v and all(isinstance(x, Mapping) for x in v):
|
|
174
|
+
rows = []
|
|
175
|
+
for o in list(v)[:100]:
|
|
176
|
+
row = {}
|
|
177
|
+
for ik, iv in o.items():
|
|
178
|
+
s = _scalar(iv)
|
|
179
|
+
if s is not None:
|
|
180
|
+
row[cut(_text(ik), 50)] = s
|
|
181
|
+
rows.append(row)
|
|
182
|
+
out[key] = rows
|
|
183
|
+
continue
|
|
184
|
+
s = _scalar(v)
|
|
185
|
+
if s is not None:
|
|
186
|
+
out[key] = s
|
|
187
|
+
return out or None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def increment(p: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
191
|
+
out: Dict[str, Any] = {}
|
|
192
|
+
for k, v in (p or {}).items():
|
|
193
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(v, float) and not math.isfinite(v):
|
|
196
|
+
continue
|
|
197
|
+
out[cut(_text(k), 50)] = v
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def append(p: Optional[Mapping[str, Any]]) -> Dict[str, List[Any]]:
|
|
202
|
+
out: Dict[str, List[Any]] = {}
|
|
203
|
+
for k, v in (p or {}).items():
|
|
204
|
+
items = list(v) if isinstance(v, (list, tuple)) else [v]
|
|
205
|
+
keep = [x for x in items if isinstance(x, (str, bool)) or (isinstance(x, (int, float)) and math.isfinite(x))]
|
|
206
|
+
if keep:
|
|
207
|
+
out[cut(_text(k), 50)] = keep[:20]
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def unset_keys(keys: Any) -> List[str]:
|
|
212
|
+
if isinstance(keys, (str, bytes)) or not isinstance(keys, (list, tuple)):
|
|
213
|
+
raise ValueError("TapCub: keys must be a list of property names")
|
|
214
|
+
return [cut(_text(k), 50) for k in keys if k is not None and _text(k) != ""][:50]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def campaign(c: Any) -> Optional[Dict[str, Any]]:
|
|
218
|
+
if not isinstance(c, Mapping):
|
|
219
|
+
return None
|
|
220
|
+
out: Dict[str, Any] = {}
|
|
221
|
+
for k, lim in _CAMPAIGN_LIMITS:
|
|
222
|
+
v = c.get(k)
|
|
223
|
+
if v is not None and _text(v) != "":
|
|
224
|
+
out[k] = cut(_text(v), lim)
|
|
225
|
+
t = c.get("touched_at")
|
|
226
|
+
if not isinstance(t, bool) and isinstance(t, (int, float)) and math.isfinite(t):
|
|
227
|
+
out["touched_at"] = int(t)
|
|
228
|
+
return out or None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def context(c: Any) -> Optional[Dict[str, Any]]:
|
|
232
|
+
if not isinstance(c, Mapping):
|
|
233
|
+
return None
|
|
234
|
+
out: Dict[str, Any] = {}
|
|
235
|
+
for k, lim in (("ip", 45), ("user_agent", 1000), ("url", 2000)):
|
|
236
|
+
v = c.get(k)
|
|
237
|
+
if v is not None and _text(v) != "":
|
|
238
|
+
out[k] = cut(_text(v), lim)
|
|
239
|
+
if c.get("platform") in PLATFORMS:
|
|
240
|
+
out["platform"] = c["platform"]
|
|
241
|
+
country = c.get("country")
|
|
242
|
+
if isinstance(country, str) and _COUNTRY.match(country):
|
|
243
|
+
out["country"] = country.upper()
|
|
244
|
+
lang = c.get("lang")
|
|
245
|
+
if lang is not None and _text(lang) != "":
|
|
246
|
+
out["lang"] = cut(_text(lang), 12)
|
|
247
|
+
camp = campaign(c.get("campaign"))
|
|
248
|
+
if camp:
|
|
249
|
+
out["campaign"] = camp
|
|
250
|
+
return out or None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def compact(ev: Mapping[str, Any]) -> Dict[str, Any]:
|
|
254
|
+
return {k: v for k, v in ev.items() if v is not None}
|