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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}