tapcub 0.1.0__py3-none-any.whl

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/__init__.py 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__"]
tapcub/_normalize.py ADDED
@@ -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}
tapcub/client.py ADDED
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import gzip as _gzip
5
+ import json
6
+ import logging
7
+ import random
8
+ import re
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ import weakref
14
+ from collections import deque
15
+ from typing import Any, Callable, Deque, Dict, List, Mapping, Optional, Tuple
16
+
17
+ from . import _normalize as n
18
+ from .version import VERSION
19
+
20
+ DEFAULT_ENDPOINT = "https://api.pathclue.com"
21
+ MAX_BODY_BYTES = 1_000_000
22
+ _log = logging.getLogger("tapcub")
23
+
24
+
25
+ class TapCubError(Exception):
26
+ """A batch that could not be delivered, or events the server rejected.
27
+
28
+ code: server code (``NO_API_KEY``, ``BAD_API_KEY``, ``VALIDATION`` …), ``HTTP_<status>``, ``NETWORK``,
29
+ ``EVENT_REJECTED`` or ``QUEUE_FULL``. ``events`` are the events that were not stored.
30
+ """
31
+
32
+ def __init__(self, message: str, code: str, status: Optional[int] = None, events: Optional[List[dict]] = None, results: Optional[List[dict]] = None):
33
+ super().__init__(message)
34
+ self.code = code
35
+ self.status = status
36
+ self.events = events or []
37
+ self.results = results
38
+ self.retry_after: Optional[float] = None
39
+
40
+
41
+ def normalize_endpoint(endpoint: Optional[str]) -> str:
42
+ e = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
43
+ e = re.sub(r"/api/v1(/track)?$", "", e)
44
+ if not re.match(r"^https?://", e):
45
+ raise ValueError("TapCub: endpoint must start with http:// or https://")
46
+ return e
47
+
48
+
49
+ def from_request_headers(headers: Mapping[str, Any]) -> Dict[str, Optional[str]]:
50
+ """Read the device and session IDs a TapCub client put on a request to your backend
51
+ (``X-Tc-Device`` / ``X-Tc-Session``). Works with Django / Flask / Starlette ``request.headers``,
52
+ a WSGI ``environ`` or a plain dict. Returns ``{"device_id": …, "session_id": …}``."""
53
+
54
+ def get(name: str) -> Optional[str]:
55
+ v = None
56
+ if hasattr(headers, "get"):
57
+ v = headers.get(name) or headers.get(name.lower()) or headers.get("HTTP_" + name.upper().replace("-", "_"))
58
+ if isinstance(v, (list, tuple)):
59
+ v = v[0] if v else None
60
+ return v.strip() or None if isinstance(v, str) else None
61
+
62
+ return {"device_id": get("X-Tc-Device"), "session_id": get("X-Tc-Session")}
63
+
64
+
65
+ class TapCub:
66
+ """TapCub server-side client.
67
+
68
+ consumer:
69
+ ``"batch"`` (default) queue in memory and send from a background thread every ``flush_interval`` seconds or
70
+ when ``max_batch`` events are queued; flushed again when the interpreter exits.
71
+ ``"sync"`` send on every call and raise ``TapCubError`` on failure.
72
+ ``"debug"`` like ``sync``, against ``?debug=1`` (validated, nothing stored), every result is logged.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ api_key: str,
78
+ endpoint: Optional[str] = None,
79
+ *,
80
+ consumer: str = "batch",
81
+ max_batch: int = 200,
82
+ flush_interval: float = 3.0,
83
+ max_queue_size: int = 10_000,
84
+ max_retries: int = 5,
85
+ timeout: float = 10.0,
86
+ gzip: bool = False,
87
+ on_error: Optional[Callable[[TapCubError], None]] = None,
88
+ logger: Optional[logging.Logger] = None,
89
+ _retry_base: float = 0.5,
90
+ ):
91
+ if not api_key or not str(api_key).strip():
92
+ raise ValueError("TapCub: api_key is required")
93
+ if consumer not in ("batch", "sync", "debug"):
94
+ raise ValueError("TapCub: consumer must be batch, sync or debug")
95
+ self.api_key = str(api_key).strip()
96
+ self.endpoint = normalize_endpoint(endpoint)
97
+ self.consumer = consumer
98
+ self.max_batch = max(1, min(1000, int(max_batch)))
99
+ self.max_queue_size = max(1, int(max_queue_size))
100
+ self.max_retries = max(0, int(max_retries))
101
+ self.timeout = timeout
102
+ self.gzip = bool(gzip)
103
+ self.logger = logger or _log
104
+ self.on_error = on_error or (lambda e: self.logger.error("[tapcub] %s", e))
105
+ self._retry_base = _retry_base
106
+ self._queue: Deque[Tuple[dict, int]] = deque()
107
+ self._dropped = 0
108
+ self._lock = threading.Lock()
109
+ self._send_lock = threading.Lock()
110
+ self._wake = threading.Event()
111
+ self._stop = threading.Event()
112
+ self._thread: Optional[threading.Thread] = None
113
+ self._flush_interval = max(0.01, float(flush_interval))
114
+ if consumer == "batch":
115
+ self._thread = threading.Thread(target=self._run, name="tapcub-sender", daemon=True)
116
+ self._thread.start()
117
+ ref = weakref.ref(self)
118
+ self._atexit = lambda: ref() and ref()._shutdown() # type: ignore[union-attr]
119
+ atexit.register(self._atexit)
120
+
121
+ # ---------------- events ----------------
122
+
123
+ def track(
124
+ self,
125
+ event: str,
126
+ *,
127
+ login_id: Optional[str] = None,
128
+ login_id_type: Optional[str] = None,
129
+ device_id: Optional[str] = None,
130
+ session_id: Optional[str] = None,
131
+ insert_id: Optional[str] = None,
132
+ time: Any = None,
133
+ group: Optional[str] = None,
134
+ properties: Optional[Mapping[str, Any]] = None,
135
+ context: Optional[Mapping[str, Any]] = None,
136
+ ) -> Optional[dict]:
137
+ """A business event. ``insert_id`` (≤36) is the idempotency key; it is not generated for you."""
138
+ kind = n.login_id_type(login_id_type)
139
+ raw = n.optional_text("login_id", login_id, 200)
140
+ return self._enqueue({
141
+ "type": "track",
142
+ "event": n.event_name(event),
143
+ "login_id": n.identity_value(kind, raw) if raw and kind in ("email", "mobile") else raw,
144
+ "login_id_type": kind,
145
+ "device_id": n.optional_text("device_id", device_id, 72),
146
+ "session_id": n.optional_text("session_id", session_id, 32),
147
+ "insert_id": n.optional_text("insert_id", insert_id, 36),
148
+ "time": n.timestamp(time),
149
+ "group": n.group(group),
150
+ "properties": n.properties(properties),
151
+ "context": n.context(context),
152
+ })
153
+
154
+ def track_signup(
155
+ self,
156
+ login_id: str,
157
+ *,
158
+ device_id: Optional[str] = None,
159
+ session_id: Optional[str] = None,
160
+ insert_id: Optional[str] = None,
161
+ time: Any = None,
162
+ properties: Optional[Mapping[str, Any]] = None,
163
+ invited_by: Optional[str] = None,
164
+ context: Optional[Mapping[str, Any]] = None,
165
+ ) -> Optional[dict]:
166
+ """Sign-up: links the anonymous device (if any) to the new login ID and records ``sign_up``.
167
+ ``invited_by`` is the inviter's login ID (sent as ``properties.$invited_by``)."""
168
+ props = dict(properties or {})
169
+ if invited_by is not None and str(invited_by).strip():
170
+ props["$invited_by"] = str(invited_by).strip()
171
+ return self._enqueue({
172
+ "type": "track_signup",
173
+ "event": "sign_up",
174
+ "login_id": n.require_text("login_id", login_id, 200),
175
+ "device_id": n.optional_text("device_id", device_id, 72),
176
+ "session_id": n.optional_text("session_id", session_id, 32),
177
+ "insert_id": n.optional_text("insert_id", insert_id, 36),
178
+ "time": n.timestamp(time),
179
+ "properties": n.properties(props),
180
+ "context": n.context(context),
181
+ })
182
+
183
+ def track_update(self, event: str, insert_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
184
+ """Merge properties into the stored event with this ``insert_id``."""
185
+ return self._enqueue({"type": "track_update", "event": n.event_name(event), "insert_id": n.require_text("insert_id", insert_id, 36), "time": n.timestamp(None), "properties": n.properties(properties)})
186
+
187
+ def track_overwrite(self, event: str, insert_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
188
+ """Replace the properties of the stored event with this ``insert_id``."""
189
+ return self._enqueue({"type": "track_overwrite", "event": n.event_name(event), "insert_id": n.require_text("insert_id", insert_id, 36), "time": n.timestamp(None), "properties": n.properties(properties)})
190
+
191
+ # ---------------- identity ----------------
192
+
193
+ def bind(self, login_id: str, identities: Mapping[str, str]) -> Optional[dict]:
194
+ """Attach more identities (``email``, ``mobile``, ``unionid``, ``openid``, ``custom``). Email and mobile are SHA-256 hashed first."""
195
+ return self._enqueue({"type": "bind", "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), "identities": n.identities(identities)})
196
+
197
+ def unbind(self, login_id: str, identity_type: str, value: str) -> Optional[dict]:
198
+ """Remove one identity from the user."""
199
+ return self._enqueue({"type": "unbind", "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), "identities": n.identities({identity_type: n.require_text("value", value)})})
200
+
201
+ # ---------------- profiles ----------------
202
+
203
+ def profile_set(self, login_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
204
+ return self._profile("profile_set", login_id, properties=n.properties(properties))
205
+
206
+ def profile_set_once(self, login_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
207
+ """Set only the properties that are not set yet."""
208
+ return self._profile("profile_set_once", login_id, properties=n.properties(properties))
209
+
210
+ def profile_increment(self, login_id: str, increment: Mapping[str, Any]) -> Optional[dict]:
211
+ return self._profile("profile_increment", login_id, increment=n.increment(increment))
212
+
213
+ def profile_append(self, login_id: str, append: Mapping[str, Any]) -> Optional[dict]:
214
+ return self._profile("profile_append", login_id, append=n.append(append))
215
+
216
+ def profile_unset(self, login_id: str, keys: List[str]) -> Optional[dict]:
217
+ return self._profile("profile_unset", login_id, unset=n.unset_keys(keys))
218
+
219
+ # ---------------- items / accounts ----------------
220
+
221
+ def item_set(self, item_type: str, item_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
222
+ """Upsert a dimension row (product, article, store …)."""
223
+ return self._enqueue({"type": "item_set", "item_type": n.require_text("item_type", item_type, 40), "item_id": n.require_text("item_id", item_id, 80), "time": n.timestamp(None), "properties": n.properties(properties)})
224
+
225
+ def item_delete(self, item_type: str, item_id: str) -> Optional[dict]:
226
+ return self._enqueue({"type": "item_delete", "item_type": n.require_text("item_type", item_type, 40), "item_id": n.require_text("item_id", item_id, 80), "time": n.timestamp(None)})
227
+
228
+ def group_set(self, group_type: str, group_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
229
+ """Set account (group) traits for ``group_type:group_id``."""
230
+ return self._enqueue({"type": "group_set", "group": n.group_key(group_type, group_id), "time": n.timestamp(None), "properties": n.properties(properties)})
231
+
232
+ # ---------------- sending ----------------
233
+
234
+ def flush(self) -> None:
235
+ """Send everything queued now. Raises the first ``TapCubError`` if a batch could not be delivered."""
236
+ errors = self._drain()
237
+ if errors:
238
+ raise errors[0]
239
+
240
+ def close(self) -> None:
241
+ """Stop the background thread and send what is queued (raises like ``flush``)."""
242
+ self._stop.set()
243
+ self._wake.set()
244
+ if self._thread and self._thread is not threading.current_thread():
245
+ self._thread.join(timeout=self.timeout * (self.max_retries + 1) + 5)
246
+ if getattr(self, "_atexit", None):
247
+ atexit.unregister(self._atexit)
248
+ self._atexit = None
249
+ self.flush()
250
+
251
+ def __enter__(self) -> "TapCub":
252
+ return self
253
+
254
+ def __exit__(self, *exc: Any) -> None:
255
+ self.close()
256
+
257
+ @property
258
+ def queued(self) -> int:
259
+ return len(self._queue)
260
+
261
+ def _profile(self, kind: str, login_id: str, **body: Any) -> Optional[dict]:
262
+ return self._enqueue({"type": kind, "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), **body})
263
+
264
+ def _enqueue(self, raw: Mapping[str, Any]) -> Optional[dict]:
265
+ ev = n.compact(raw)
266
+ if self.consumer != "batch":
267
+ return self._send([ev], self.consumer == "debug")
268
+ size = len(json.dumps(ev, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) + 1
269
+ with self._lock:
270
+ self._queue.append((ev, size))
271
+ over = len(self._queue) - self.max_queue_size
272
+ for _ in range(max(0, over)):
273
+ self._queue.popleft()
274
+ self._dropped += 1
275
+ full = len(self._queue) >= self.max_batch
276
+ if full:
277
+ self._wake.set()
278
+ return None
279
+
280
+ def _run(self) -> None:
281
+ while not self._stop.is_set():
282
+ self._wake.wait(self._flush_interval)
283
+ self._wake.clear()
284
+ if self._stop.is_set():
285
+ break
286
+ for e in self._drain():
287
+ self._report(e)
288
+
289
+ def _shutdown(self) -> None:
290
+ try:
291
+ self.close()
292
+ except TapCubError as e:
293
+ self._report(e)
294
+
295
+ def _report(self, err: TapCubError) -> None:
296
+ try:
297
+ self.on_error(err)
298
+ except Exception: # noqa: BLE001
299
+ self.logger.exception("[tapcub] on_error raised")
300
+
301
+ def _take(self) -> List[dict]:
302
+ with self._lock:
303
+ batch: List[dict] = []
304
+ size = 0
305
+ while self._queue and len(batch) < self.max_batch:
306
+ ev, b = self._queue[0]
307
+ if batch and size + b > MAX_BODY_BYTES:
308
+ break
309
+ self._queue.popleft()
310
+ batch.append(ev)
311
+ size += b
312
+ return batch
313
+
314
+ def _drain(self) -> List[TapCubError]:
315
+ errors: List[TapCubError] = []
316
+ with self._send_lock:
317
+ with self._lock:
318
+ dropped, self._dropped = self._dropped, 0
319
+ if dropped:
320
+ errors.append(TapCubError(f"TapCub: queue full, dropped {dropped} oldest event(s)", "QUEUE_FULL"))
321
+ while True:
322
+ batch = self._take()
323
+ if not batch:
324
+ break
325
+ try:
326
+ self._send(batch, False)
327
+ except TapCubError as e:
328
+ errors.append(e)
329
+ return errors
330
+
331
+ def _post(self, url: str, body: bytes, headers: Dict[str, str]) -> Tuple[int, Dict[str, str], bytes]:
332
+ """One HTTP request. Returns (status, headers, body); raises OSError on network errors."""
333
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
334
+ try:
335
+ with urllib.request.urlopen(req, timeout=self.timeout) as r: # noqa: S310 (endpoint is configured by the caller)
336
+ return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
337
+ except urllib.error.HTTPError as e:
338
+ return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read() or b""
339
+
340
+ def _send(self, events: List[dict], debug: bool) -> dict:
341
+ url = f"{self.endpoint}/api/v1/track" + ("?debug=1" if debug else "")
342
+ body = json.dumps({"events": events}, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
343
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", "User-Agent": f"tapcub-python/{VERSION}"}
344
+ if self.gzip:
345
+ body = _gzip.compress(body)
346
+ headers["Content-Encoding"] = "gzip"
347
+ last: Optional[TapCubError] = None
348
+ for attempt in range(self.max_retries + 1):
349
+ if attempt:
350
+ time.sleep(self._backoff(attempt, last))
351
+ try:
352
+ status, res_headers, raw = self._post(url, body, headers)
353
+ except (OSError, ValueError) as e:
354
+ last = TapCubError(f"TapCub: request failed: {e}", "NETWORK", events=events)
355
+ continue
356
+ try:
357
+ data = json.loads(raw.decode("utf-8")) if raw else {}
358
+ except ValueError:
359
+ data = {}
360
+ if not isinstance(data, dict):
361
+ data = {}
362
+ if 200 <= status < 300:
363
+ results = data.get("results") or []
364
+ if debug:
365
+ for r in results:
366
+ w = r.get("warnings") or []
367
+ self.logger.warning("[tapcub] debug #%s %s%s", r.get("index"), "ok" if r.get("ok") else "rejected", f" warnings={','.join(w)}" if w else "")
368
+ bad = [r for r in results if not r.get("ok")]
369
+ if bad:
370
+ raise TapCubError(
371
+ "TapCub: %d event(s) rejected: %s" % (len(bad), "; ".join(f"#{r.get('index')} {','.join(r.get('warnings') or []) or 'rejected'}" for r in bad)),
372
+ "EVENT_REJECTED", status, [events[r["index"]] for r in bad if isinstance(r.get("index"), int) and r["index"] < len(events)], bad,
373
+ )
374
+ data.setdefault("results", [])
375
+ return data
376
+ code = data.get("code") if isinstance(data.get("code"), str) else f"HTTP_{status}"
377
+ msg = data.get("message") if isinstance(data.get("message"), str) else raw[:200].decode("utf-8", "replace")
378
+ issues = data.get("issues")
379
+ detail = f" ({'; '.join(str(i) for i in issues[:3])})" if isinstance(issues, list) and issues else ""
380
+ last = TapCubError(f"TapCub: HTTP {status} {code}: {msg}{detail}", code, status, events)
381
+ try:
382
+ last.retry_after = float(res_headers.get("retry-after", "") or 0) or None
383
+ except ValueError:
384
+ last.retry_after = None
385
+ if status != 429 and status < 500:
386
+ raise last
387
+ assert last is not None
388
+ raise last
389
+
390
+ def _backoff(self, attempt: int, last: Optional[TapCubError]) -> float:
391
+ if last is not None and last.retry_after:
392
+ return min(60.0, last.retry_after)
393
+ base = min(30.0, self._retry_base * 2 ** (attempt - 1))
394
+ return base / 2 + random.random() * base / 2
tapcub/py.typed ADDED
File without changes
tapcub/version.py ADDED
@@ -0,0 +1,2 @@
1
+ # 与 pyproject.toml 的 version 一致(tests/test_client.py 核对)
2
+ VERSION = "0.1.0"
@@ -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
@@ -0,0 +1,10 @@
1
+ tapcub/__init__.py,sha256=TXJjsc9XvslAewXdiJkML7JcDWgJsSp2Nux3bml1vSo,555
2
+ tapcub/_normalize.py,sha256=gJUjfhNYwzW_qUMLxpo5EkwPvNRqHi7VINdf0RH4rGU,8313
3
+ tapcub/client.py,sha256=kaGF5MRw2tk-UdTq7EoAqgMX2R74WkNwufqrUedCJkI,18176
4
+ tapcub/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ tapcub/version.py,sha256=7A1L6aMdetMdBEyE0CrEW421HDdf4mSPmJ6Nf2hC0lc,91
6
+ tapcub-0.1.0.dist-info/licenses/LICENSE,sha256=90A_5sifmt1zqDMduHCaUvIJLVzx6XWjwbeqe8aW_OA,1063
7
+ tapcub-0.1.0.dist-info/METADATA,sha256=Jg3GC6rnXyswOugQqN7P_Ft402QoO23XWW2apxSeeZg,8418
8
+ tapcub-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ tapcub-0.1.0.dist-info/top_level.txt,sha256=LfmhldBVVDkOOg9vKkA64NROyLn1u7H-0r8p18WOS2I,7
10
+ tapcub-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ tapcub