trodo-python 1.0.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.
@@ -0,0 +1,106 @@
1
+ """Group management methods and GroupProfile proxy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, List, Optional, Union
6
+
7
+ from ..api import endpoints
8
+ from ..types import ApiResult
9
+
10
+
11
+ class GroupProfile:
12
+ def __init__(
13
+ self,
14
+ http_client: object,
15
+ site_id: str,
16
+ distinct_id: str,
17
+ group_key: str,
18
+ group_id: str,
19
+ ) -> None:
20
+ self._http = http_client
21
+ self._site_id = site_id
22
+ self._distinct_id = distinct_id
23
+ self._group_key = group_key
24
+ self._group_id = group_id
25
+
26
+ def _base(self) -> Dict[str, Any]:
27
+ return {
28
+ "siteId": self._site_id,
29
+ "userId": self._distinct_id,
30
+ "groupKey": self._group_key,
31
+ "groupId": self._group_id,
32
+ }
33
+
34
+ def _call(self, method: str, extra: Optional[Dict[str, Any]] = None) -> ApiResult:
35
+ payload = {**self._base(), **(extra or {})}
36
+ return self._http.post_group( # type: ignore[attr-defined]
37
+ f"{endpoints.GROUPS_PROFILE}/{method}", payload
38
+ )
39
+
40
+ def set(self, properties: Dict[str, Any]) -> ApiResult:
41
+ return self._call("set", {"properties": properties})
42
+
43
+ def set_once(self, properties: Dict[str, Any]) -> ApiResult:
44
+ return self._call("set_once", {"properties": properties})
45
+
46
+ def union(self, list_name: str, values: List[Any]) -> ApiResult:
47
+ return self._call("union", {"listName": list_name, "values": values})
48
+
49
+ def remove(self, list_name: str, value: Any) -> ApiResult:
50
+ return self._call("remove", {"listName": list_name, "value": value})
51
+
52
+ def unset(self, property: str) -> ApiResult:
53
+ return self._call("unset", {"property": property})
54
+
55
+ def increment(self, property: str, value: float = 1) -> ApiResult:
56
+ return self._call("increment", {"property": property, "value": value})
57
+
58
+ def append(self, property: str, value: Any) -> ApiResult:
59
+ return self._call("append", {"property": property, "value": value})
60
+
61
+ def delete(self) -> ApiResult:
62
+ return self._call("delete")
63
+
64
+
65
+ class GroupManager:
66
+ def __init__(
67
+ self,
68
+ http_client: object,
69
+ site_id: str,
70
+ get_distinct_id: Callable[[], str],
71
+ ) -> None:
72
+ self._http = http_client
73
+ self._site_id = site_id
74
+ self._get_distinct_id = get_distinct_id
75
+
76
+ def _base(self) -> Dict[str, Any]:
77
+ return {"siteId": self._site_id, "userId": self._get_distinct_id()}
78
+
79
+ def set_group(
80
+ self, group_key: str, group_id: Union[str, List[str]]
81
+ ) -> ApiResult:
82
+ return self._http.post_group( # type: ignore[attr-defined]
83
+ endpoints.GROUPS_SET,
84
+ {**self._base(), "groupKey": group_key, "groupId": group_id},
85
+ )
86
+
87
+ def add_group(self, group_key: str, group_id: str) -> ApiResult:
88
+ return self._http.post_group( # type: ignore[attr-defined]
89
+ endpoints.GROUPS_ADD,
90
+ {**self._base(), "groupKey": group_key, "groupId": group_id},
91
+ )
92
+
93
+ def remove_group(self, group_key: str, group_id: str) -> ApiResult:
94
+ return self._http.post_group( # type: ignore[attr-defined]
95
+ endpoints.GROUPS_REMOVE,
96
+ {**self._base(), "groupKey": group_key, "groupId": group_id},
97
+ )
98
+
99
+ def get_group(self, group_key: str, group_id: str) -> GroupProfile:
100
+ return GroupProfile(
101
+ self._http,
102
+ self._site_id,
103
+ self._get_distinct_id(),
104
+ group_key,
105
+ group_id,
106
+ )
@@ -0,0 +1,77 @@
1
+ """People (user profile) methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, List, Optional, Union
6
+
7
+ from ..api import endpoints
8
+ from ..types import ApiResult
9
+
10
+
11
+ class PeopleManager:
12
+ def __init__(
13
+ self,
14
+ http_client: object,
15
+ site_id: str,
16
+ get_distinct_id: Callable[[], str],
17
+ ) -> None:
18
+ self._http = http_client
19
+ self._site_id = site_id
20
+ self._get_distinct_id = get_distinct_id
21
+
22
+ def _base(self) -> Dict[str, Any]:
23
+ return {"siteId": self._site_id, "userId": self._get_distinct_id()}
24
+
25
+ def set(self, properties: Dict[str, Any]) -> ApiResult:
26
+ return self._http.post_people( # type: ignore[attr-defined]
27
+ endpoints.PEOPLE_SET, {**self._base(), "properties": properties}
28
+ )
29
+
30
+ def set_once(self, properties: Dict[str, Any]) -> ApiResult:
31
+ return self._http.post_people( # type: ignore[attr-defined]
32
+ endpoints.PEOPLE_SET_ONCE, {**self._base(), "properties": properties}
33
+ )
34
+
35
+ def unset(self, keys: Union[str, List[str]]) -> ApiResult:
36
+ k = [keys] if isinstance(keys, str) else keys
37
+ return self._http.post_people( # type: ignore[attr-defined]
38
+ endpoints.PEOPLE_UNSET, {**self._base(), "keys": k}
39
+ )
40
+
41
+ def increment(self, key: str, amount: float = 1) -> ApiResult:
42
+ return self._http.post_people( # type: ignore[attr-defined]
43
+ endpoints.PEOPLE_INCREMENT, {**self._base(), "key": key, "amount": amount}
44
+ )
45
+
46
+ def append(self, key: str, values: List[Any]) -> ApiResult:
47
+ return self._http.post_people( # type: ignore[attr-defined]
48
+ endpoints.PEOPLE_APPEND, {**self._base(), "key": key, "values": values}
49
+ )
50
+
51
+ def union(self, key: str, values: List[Any]) -> ApiResult:
52
+ return self._http.post_people( # type: ignore[attr-defined]
53
+ endpoints.PEOPLE_UNION, {**self._base(), "key": key, "values": values}
54
+ )
55
+
56
+ def remove(self, key: str, values: List[Any]) -> ApiResult:
57
+ return self._http.post_people( # type: ignore[attr-defined]
58
+ endpoints.PEOPLE_REMOVE, {**self._base(), "key": key, "values": values}
59
+ )
60
+
61
+ def track_charge(
62
+ self, amount: float, properties: Optional[Dict[str, Any]] = None
63
+ ) -> ApiResult:
64
+ return self._http.post_people( # type: ignore[attr-defined]
65
+ endpoints.PEOPLE_TRACK_CHARGE,
66
+ {**self._base(), "amount": amount, "properties": properties or {}},
67
+ )
68
+
69
+ def clear_charges(self) -> ApiResult:
70
+ return self._http.post_people( # type: ignore[attr-defined]
71
+ endpoints.PEOPLE_CLEAR_CHARGES, self._base()
72
+ )
73
+
74
+ def delete_user(self) -> ApiResult:
75
+ return self._http.post_people( # type: ignore[attr-defined]
76
+ endpoints.PEOPLE_DELETE_USER, self._base()
77
+ )
File without changes
@@ -0,0 +1,52 @@
1
+ """Background timer-based batch flusher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Optional
7
+
8
+ from .event_queue import EventQueue
9
+
10
+
11
+ class BatchFlusher:
12
+ def __init__(
13
+ self,
14
+ queue: EventQueue,
15
+ http_client: object,
16
+ flush_interval: float,
17
+ ) -> None:
18
+ self._queue = queue
19
+ self._http = http_client
20
+ self._flush_interval = flush_interval
21
+ self._timer: Optional[threading.Timer] = None
22
+ self._lock = threading.Lock()
23
+
24
+ def start(self) -> None:
25
+ self._reschedule()
26
+
27
+ def stop(self) -> None:
28
+ with self._lock:
29
+ if self._timer:
30
+ self._timer.cancel()
31
+ self._timer = None
32
+
33
+ def flush(self) -> None:
34
+ events = self._queue.drain()
35
+ if not events:
36
+ return
37
+ self._http.post_bulk_events(events) # type: ignore[attr-defined]
38
+
39
+ def _reschedule(self) -> None:
40
+ with self._lock:
41
+ if self._timer:
42
+ self._timer.cancel()
43
+ t = threading.Timer(self._flush_interval, self._tick)
44
+ t.daemon = True
45
+ t.start()
46
+ self._timer = t
47
+
48
+ def _tick(self) -> None:
49
+ try:
50
+ self.flush()
51
+ finally:
52
+ self._reschedule()
@@ -0,0 +1,32 @@
1
+ """Thread-safe in-memory event queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import List
7
+
8
+ from ..types import EventPayload
9
+
10
+
11
+ class EventQueue:
12
+ def __init__(self, max_size: int = 500) -> None:
13
+ self._max_size = max_size
14
+ self._queue: List[EventPayload] = []
15
+ self._lock = threading.Lock()
16
+
17
+ def enqueue(self, event: EventPayload) -> bool:
18
+ """Returns True if flush threshold reached."""
19
+ with self._lock:
20
+ self._queue.append(event)
21
+ return len(self._queue) >= self._max_size
22
+
23
+ def drain(self) -> List[EventPayload]:
24
+ """Atomically take all queued events."""
25
+ with self._lock:
26
+ events = list(self._queue)
27
+ self._queue.clear()
28
+ return events
29
+
30
+ def size(self) -> int:
31
+ with self._lock:
32
+ return len(self._queue)
File without changes
@@ -0,0 +1,74 @@
1
+ """Server session creation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, Optional
9
+
10
+ from ..types import ServerSession
11
+
12
+
13
+ def now_iso() -> str:
14
+ return datetime.now(timezone.utc).isoformat()
15
+
16
+
17
+ def create_server_session(
18
+ site_id: str,
19
+ distinct_id: str,
20
+ session_id: Optional[str] = None,
21
+ ) -> ServerSession:
22
+ return ServerSession(
23
+ session_id=session_id or str(uuid.uuid4()),
24
+ site_id=site_id,
25
+ distinct_id=distinct_id,
26
+ start_time=now_iso(),
27
+ last_activity=time.time(),
28
+ confirmed=False,
29
+ )
30
+
31
+
32
+ def build_session_payload(session: ServerSession) -> Dict[str, Any]:
33
+ return {
34
+ "session_id": session.session_id,
35
+ "site_id": session.site_id,
36
+ "user_id": session.distinct_id,
37
+ "distinct_id": session.distinct_id,
38
+ "team_id": None,
39
+ "start_time": session.start_time,
40
+ "end_time": None,
41
+ "last_activity": int(session.last_activity * 1000),
42
+ "duration": 0,
43
+ "pages_viewed": 0,
44
+ "is_bounce": False,
45
+ "previous_session_id": None,
46
+ "time_since_last_session": None,
47
+ "entry_page": None,
48
+ "exit_page": None,
49
+ "referrer": "server",
50
+ "ip_address": None,
51
+ "city": None,
52
+ "region": None,
53
+ "country": None,
54
+ "browser_name": None,
55
+ "browser_version": None,
56
+ "device_type": "server",
57
+ "os": None,
58
+ "resolution": None,
59
+ "user_agent": None,
60
+ "language": None,
61
+ "wallet_address": None,
62
+ "wallet_type": None,
63
+ "chain_name": None,
64
+ "is_web3_user": False,
65
+ "wallet_connected": False,
66
+ "utm_source": None,
67
+ "utm_medium": None,
68
+ "utm_campaign": None,
69
+ "utm_term": None,
70
+ "utm_content": None,
71
+ "utm_id": None,
72
+ "visited_pages": [],
73
+ "active_time_ms": 0,
74
+ }
@@ -0,0 +1,74 @@
1
+ """Thread-safe in-process session cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Dict, Optional
7
+
8
+ from ..types import ServerSession
9
+ from .server_session import build_session_payload, create_server_session
10
+
11
+
12
+ class SessionManager:
13
+ def __init__(self) -> None:
14
+ self._sessions: Dict[str, ServerSession] = {}
15
+ self._lock = threading.Lock()
16
+ self._confirmation_locks: Dict[str, threading.Event] = {}
17
+ self._confirmation_started: Dict[str, bool] = {}
18
+
19
+ def get_or_create(
20
+ self,
21
+ distinct_id: str,
22
+ site_id: str,
23
+ session_id: Optional[str] = None,
24
+ ) -> ServerSession:
25
+ with self._lock:
26
+ if distinct_id in self._sessions:
27
+ return self._sessions[distinct_id]
28
+ session = create_server_session(site_id, distinct_id, session_id)
29
+ self._sessions[distinct_id] = session
30
+ return session
31
+
32
+ def ensure_confirmed(self, session: ServerSession, http_client: object) -> None:
33
+ """
34
+ Block until the session has been confirmed via POST /api/sdk/track.
35
+ Idempotent — concurrent callers share a single confirmation attempt.
36
+ """
37
+ if session.confirmed:
38
+ return
39
+
40
+ key = session.distinct_id
41
+
42
+ with self._lock:
43
+ if session.confirmed:
44
+ return
45
+ # Check if confirmation already in-flight
46
+ if key not in self._confirmation_locks:
47
+ event = threading.Event()
48
+ self._confirmation_locks[key] = event
49
+ should_run = True
50
+ else:
51
+ event = self._confirmation_locks[key]
52
+ should_run = False
53
+
54
+ if should_run:
55
+ try:
56
+ payload = build_session_payload(session)
57
+ http_client.post_track(payload) # type: ignore[attr-defined]
58
+ except Exception:
59
+ pass # Confirmation failure is non-fatal
60
+ finally:
61
+ session.confirmed = True
62
+ with self._lock:
63
+ self._confirmation_locks.pop(key, None)
64
+ event.set()
65
+ else:
66
+ event.wait(timeout=10)
67
+
68
+ def invalidate(self, distinct_id: str) -> None:
69
+ with self._lock:
70
+ self._sessions.pop(distinct_id, None)
71
+ self._confirmation_locks.pop(distinct_id, None)
72
+
73
+ def get_global_session(self, site_id: str) -> ServerSession:
74
+ return self.get_or_create("server_global", site_id)
trodo/types.py ADDED
@@ -0,0 +1,79 @@
1
+ """Type definitions for the Trodo Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ # ----------------------------------------------------------------------------
9
+ # Configuration
10
+ # ----------------------------------------------------------------------------
11
+
12
+ @dataclass
13
+ class TrodoConfig:
14
+ site_id: str
15
+ api_base: str = "https://sdkapi.trodo.ai"
16
+ timeout: int = 10
17
+ retries: int = 2
18
+ batch_enabled: bool = False
19
+ batch_size: int = 50
20
+ batch_flush_interval: float = 5.0
21
+ auto_events: bool = False
22
+ on_error: Optional[Any] = None # Callable[[Exception], None]
23
+ debug: bool = False
24
+
25
+
26
+ # ----------------------------------------------------------------------------
27
+ # Session
28
+ # ----------------------------------------------------------------------------
29
+
30
+ @dataclass
31
+ class ServerSession:
32
+ session_id: str
33
+ site_id: str
34
+ distinct_id: str
35
+ start_time: str # ISO 8601
36
+ last_activity: float # epoch seconds
37
+ confirmed: bool = False
38
+
39
+
40
+ # ----------------------------------------------------------------------------
41
+ # Events
42
+ # ----------------------------------------------------------------------------
43
+
44
+ @dataclass
45
+ class EventPayload:
46
+ event_type: str # 'custom' | 'auto'
47
+ event_name: str
48
+ event_category: str
49
+ session_id: str
50
+ user_id: str
51
+ custom_properties: Dict[str, Any] = field(default_factory=dict)
52
+ page_data: None = None
53
+ auto_event_data: Optional[Dict[str, Any]] = None
54
+ element_data: None = None
55
+ override_data: None = None
56
+
57
+ def to_dict(self) -> Dict[str, Any]:
58
+ return {
59
+ "event_type": self.event_type,
60
+ "event_name": self.event_name,
61
+ "event_category": self.event_category,
62
+ "session_id": self.session_id,
63
+ "user_id": self.user_id,
64
+ "custom_properties": self.custom_properties,
65
+ "page_data": self.page_data,
66
+ "auto_event_data": self.auto_event_data,
67
+ "element_data": self.element_data,
68
+ "override_data": self.override_data,
69
+ }
70
+
71
+
72
+ # ----------------------------------------------------------------------------
73
+ # Results
74
+ # ----------------------------------------------------------------------------
75
+
76
+ ApiResult = Dict[str, Any]
77
+ IdentifyResult = Dict[str, Any]
78
+ WalletAddressResult = Dict[str, Any]
79
+ ResetResult = Dict[str, Any]