contackd-sync 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.
- contackd_sync-0.1.0/PKG-INFO +66 -0
- contackd_sync-0.1.0/README.md +56 -0
- contackd_sync-0.1.0/contackd_sync/__init__.py +45 -0
- contackd_sync-0.1.0/contackd_sync/client.py +545 -0
- contackd_sync-0.1.0/contackd_sync.egg-info/PKG-INFO +66 -0
- contackd_sync-0.1.0/contackd_sync.egg-info/SOURCES.txt +9 -0
- contackd_sync-0.1.0/contackd_sync.egg-info/dependency_links.txt +1 -0
- contackd_sync-0.1.0/contackd_sync.egg-info/requires.txt +2 -0
- contackd_sync-0.1.0/contackd_sync.egg-info/top_level.txt +1 -0
- contackd_sync-0.1.0/pyproject.toml +21 -0
- contackd_sync-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contackd-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Contackd Sync
|
|
5
|
+
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
Requires-Dist: urllib3>=2.0.0
|
|
10
|
+
|
|
11
|
+
# contackd-sync
|
|
12
|
+
|
|
13
|
+
Python SDK for the Contackd Sync API.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install requests urllib3
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install the package from this repository once published as a build artifact.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from contackd_sync import ContackdClient, Contact
|
|
27
|
+
|
|
28
|
+
client = ContackdClient(
|
|
29
|
+
api_key="sk-your-key",
|
|
30
|
+
base_url="https://api.yourapp.com/v1",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
contact = client.contacts.upsert(Contact(email="alice@example.com", first_name="Alice"))
|
|
34
|
+
print(contact)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Public API
|
|
38
|
+
|
|
39
|
+
The package exports the main client, typed resource wrappers, models, sync helpers, and exceptions from `contackd_sync`.
|
|
40
|
+
|
|
41
|
+
- `ContackdClient`
|
|
42
|
+
- `ContactsResource`, `LeadsResource`, `ProspectsResource`
|
|
43
|
+
- `Contact`, `Lead`, `Prospect`
|
|
44
|
+
- `LeadStatus`, `PipelineStage`
|
|
45
|
+
- `SyncEngine`, `SyncResult`, `BulkResult`
|
|
46
|
+
- `ContackdError` and specialized exceptions
|
|
47
|
+
|
|
48
|
+
## API Key Provisioning
|
|
49
|
+
|
|
50
|
+
Create and revoke SDK keys from backend auth endpoints:
|
|
51
|
+
|
|
52
|
+
1. Create key (JWT required):
|
|
53
|
+
```bash
|
|
54
|
+
curl -X POST http://localhost:8000/api/v1/auth/api-keys \
|
|
55
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
|
56
|
+
-H "Content-Type: application/json" \
|
|
57
|
+
-d '{"name":"py-sdk"}'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Delete/revoke key:
|
|
61
|
+
```bash
|
|
62
|
+
curl -X DELETE http://localhost:8000/api/v1/auth/api-keys/<API_KEY_ID> \
|
|
63
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use returned `api_key` as `api_key` when creating `ContackdClient`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# contackd-sync
|
|
2
|
+
|
|
3
|
+
Python SDK for the Contackd Sync API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install requests urllib3
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install the package from this repository once published as a build artifact.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from contackd_sync import ContackdClient, Contact
|
|
17
|
+
|
|
18
|
+
client = ContackdClient(
|
|
19
|
+
api_key="sk-your-key",
|
|
20
|
+
base_url="https://api.yourapp.com/v1",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
contact = client.contacts.upsert(Contact(email="alice@example.com", first_name="Alice"))
|
|
24
|
+
print(contact)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Public API
|
|
28
|
+
|
|
29
|
+
The package exports the main client, typed resource wrappers, models, sync helpers, and exceptions from `contackd_sync`.
|
|
30
|
+
|
|
31
|
+
- `ContackdClient`
|
|
32
|
+
- `ContactsResource`, `LeadsResource`, `ProspectsResource`
|
|
33
|
+
- `Contact`, `Lead`, `Prospect`
|
|
34
|
+
- `LeadStatus`, `PipelineStage`
|
|
35
|
+
- `SyncEngine`, `SyncResult`, `BulkResult`
|
|
36
|
+
- `ContackdError` and specialized exceptions
|
|
37
|
+
|
|
38
|
+
## API Key Provisioning
|
|
39
|
+
|
|
40
|
+
Create and revoke SDK keys from backend auth endpoints:
|
|
41
|
+
|
|
42
|
+
1. Create key (JWT required):
|
|
43
|
+
```bash
|
|
44
|
+
curl -X POST http://localhost:8000/api/v1/auth/api-keys \
|
|
45
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
|
46
|
+
-H "Content-Type: application/json" \
|
|
47
|
+
-d '{"name":"py-sdk"}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. Delete/revoke key:
|
|
51
|
+
```bash
|
|
52
|
+
curl -X DELETE http://localhost:8000/api/v1/auth/api-keys/<API_KEY_ID> \
|
|
53
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use returned `api_key` as `api_key` when creating `ContackdClient`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Contackd Sync Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
AuthError,
|
|
5
|
+
BulkResult,
|
|
6
|
+
ConflictStrategy,
|
|
7
|
+
Contact,
|
|
8
|
+
ContactsResource,
|
|
9
|
+
ContackdClient,
|
|
10
|
+
ContackdError,
|
|
11
|
+
Lead,
|
|
12
|
+
LeadStatus,
|
|
13
|
+
LeadsResource,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PipelineStage,
|
|
16
|
+
Prospect,
|
|
17
|
+
ProspectsResource,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
RecordType,
|
|
20
|
+
SyncConflictError,
|
|
21
|
+
SyncEngine,
|
|
22
|
+
SyncResult,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AuthError",
|
|
27
|
+
"BulkResult",
|
|
28
|
+
"ConflictStrategy",
|
|
29
|
+
"Contact",
|
|
30
|
+
"ContactsResource",
|
|
31
|
+
"ContackdClient",
|
|
32
|
+
"ContackdError",
|
|
33
|
+
"Lead",
|
|
34
|
+
"LeadStatus",
|
|
35
|
+
"LeadsResource",
|
|
36
|
+
"NotFoundError",
|
|
37
|
+
"PipelineStage",
|
|
38
|
+
"Prospect",
|
|
39
|
+
"ProspectsResource",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"RecordType",
|
|
42
|
+
"SyncConflictError",
|
|
43
|
+
"SyncEngine",
|
|
44
|
+
"SyncResult",
|
|
45
|
+
]
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Callable, Dict, Generic, List, Optional, Protocol, TypeVar, Literal
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from requests.adapters import HTTPAdapter
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContackdError(Exception):
|
|
16
|
+
"""Base exception raised by the Contackd Sync SDK."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthError(ContackdError):
|
|
20
|
+
"""Raised when the API key is invalid or missing."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotFoundError(ContackdError):
|
|
24
|
+
"""Raised when a requested record does not exist on the remote API."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(ContackdError):
|
|
28
|
+
"""Raised when the remote API returns HTTP 429."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncConflictError(ContackdError):
|
|
32
|
+
"""Raised when a bidirectional sync conflict cannot be auto-resolved."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, record_type: str, record_id: str, local: dict, remote: dict):
|
|
35
|
+
self.record_type = record_type
|
|
36
|
+
self.record_id = record_id
|
|
37
|
+
self.local = local
|
|
38
|
+
self.remote = remote
|
|
39
|
+
super().__init__(
|
|
40
|
+
f"Conflict on {record_type} id={record_id}. "
|
|
41
|
+
"Resolve manually or pass conflict_strategy='local'|'remote'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LeadStatus(str, Enum):
|
|
46
|
+
"""Supported lead lifecycle states."""
|
|
47
|
+
|
|
48
|
+
NEW = "new"
|
|
49
|
+
CONTACTED = "contacted"
|
|
50
|
+
QUALIFIED = "qualified"
|
|
51
|
+
UNQUALIFIED = "unqualified"
|
|
52
|
+
CONVERTED = "converted"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PipelineStage(str, Enum):
|
|
56
|
+
"""Supported prospect pipeline stages."""
|
|
57
|
+
|
|
58
|
+
AWARENESS = "awareness"
|
|
59
|
+
INTEREST = "interest"
|
|
60
|
+
CONSIDERATION = "consideration"
|
|
61
|
+
INTENT = "intent"
|
|
62
|
+
EVALUATION = "evaluation"
|
|
63
|
+
PURCHASE = "purchase"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Contact:
|
|
68
|
+
"""Contact record model used by the Contacts API."""
|
|
69
|
+
|
|
70
|
+
email: str
|
|
71
|
+
first_name: str = ""
|
|
72
|
+
last_name: str = ""
|
|
73
|
+
phone: Optional[str] = None
|
|
74
|
+
tags: List[str] = field(default_factory=list)
|
|
75
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
id: Optional[str] = None
|
|
77
|
+
updated_at: Optional[str] = None
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict:
|
|
80
|
+
"""Serialize the record to a JSON-friendly dictionary."""
|
|
81
|
+
data = asdict(self)
|
|
82
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, data: dict) -> "Contact":
|
|
86
|
+
"""Build a contact from API response data."""
|
|
87
|
+
return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
|
|
88
|
+
|
|
89
|
+
def fingerprint(self) -> str:
|
|
90
|
+
"""Return a stable SHA-256 digest for change detection."""
|
|
91
|
+
payload = json.dumps(
|
|
92
|
+
{key: value for key, value in self.to_dict().items() if key not in ("id", "updated_at")},
|
|
93
|
+
sort_keys=True,
|
|
94
|
+
)
|
|
95
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Lead:
|
|
100
|
+
"""Lead record model used by the Leads API."""
|
|
101
|
+
|
|
102
|
+
email: str
|
|
103
|
+
first_name: str = ""
|
|
104
|
+
last_name: str = ""
|
|
105
|
+
source: Optional[str] = None
|
|
106
|
+
score: int = 0
|
|
107
|
+
status: LeadStatus = LeadStatus.NEW
|
|
108
|
+
tags: List[str] = field(default_factory=list)
|
|
109
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
id: Optional[str] = None
|
|
111
|
+
updated_at: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict:
|
|
114
|
+
"""Serialize the record to a JSON-friendly dictionary."""
|
|
115
|
+
data = asdict(self)
|
|
116
|
+
data["status"] = self.status.value
|
|
117
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, data: dict) -> "Lead":
|
|
121
|
+
"""Build a lead from API response data."""
|
|
122
|
+
payload = dict(data)
|
|
123
|
+
if "status" in payload:
|
|
124
|
+
payload["status"] = LeadStatus(payload["status"])
|
|
125
|
+
return cls(**{key: value for key, value in payload.items() if key in cls.__dataclass_fields__})
|
|
126
|
+
|
|
127
|
+
def fingerprint(self) -> str:
|
|
128
|
+
"""Return a stable SHA-256 digest for change detection."""
|
|
129
|
+
payload = json.dumps(
|
|
130
|
+
{key: value for key, value in self.to_dict().items() if key not in ("id", "updated_at")},
|
|
131
|
+
sort_keys=True,
|
|
132
|
+
)
|
|
133
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Prospect:
|
|
138
|
+
"""Prospect record model used by the Prospects API."""
|
|
139
|
+
|
|
140
|
+
email: str
|
|
141
|
+
first_name: str = ""
|
|
142
|
+
last_name: str = ""
|
|
143
|
+
stage: PipelineStage = PipelineStage.AWARENESS
|
|
144
|
+
notes: str = ""
|
|
145
|
+
estimated_value: Optional[float] = None
|
|
146
|
+
close_date: Optional[str] = None
|
|
147
|
+
tags: List[str] = field(default_factory=list)
|
|
148
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
149
|
+
id: Optional[str] = None
|
|
150
|
+
updated_at: Optional[str] = None
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> dict:
|
|
153
|
+
"""Serialize the record to a JSON-friendly dictionary."""
|
|
154
|
+
data = asdict(self)
|
|
155
|
+
data["stage"] = self.stage.value
|
|
156
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_dict(cls, data: dict) -> "Prospect":
|
|
160
|
+
"""Build a prospect from API response data."""
|
|
161
|
+
payload = dict(data)
|
|
162
|
+
if "stage" in payload:
|
|
163
|
+
payload["stage"] = PipelineStage(payload["stage"])
|
|
164
|
+
return cls(**{key: value for key, value in payload.items() if key in cls.__dataclass_fields__})
|
|
165
|
+
|
|
166
|
+
def fingerprint(self) -> str:
|
|
167
|
+
"""Return a stable SHA-256 digest for change detection."""
|
|
168
|
+
payload = json.dumps(
|
|
169
|
+
{key: value for key, value in self.to_dict().items() if key not in ("id", "updated_at")},
|
|
170
|
+
sort_keys=True,
|
|
171
|
+
)
|
|
172
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
RecordType = Contact | Lead | Prospect
|
|
176
|
+
ConflictStrategy = Literal["local", "remote", "raise"]
|
|
177
|
+
RecordT = TypeVar("RecordT", Contact, Lead, Prospect)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _RecordModel(Protocol):
|
|
181
|
+
email: str
|
|
182
|
+
id: Optional[str]
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict: ...
|
|
185
|
+
|
|
186
|
+
def fingerprint(self) -> str: ...
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _RecordFactory(Protocol[RecordT]):
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, data: dict) -> RecordT: ...
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _HttpClient:
|
|
195
|
+
"""Thin wrapper around requests with retry and auth headers."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
|
198
|
+
self.base_url = base_url.rstrip("/")
|
|
199
|
+
self.timeout = timeout
|
|
200
|
+
|
|
201
|
+
retry = Retry(
|
|
202
|
+
total=3,
|
|
203
|
+
backoff_factor=0.5,
|
|
204
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
205
|
+
allowed_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
206
|
+
)
|
|
207
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
208
|
+
self.session = requests.Session()
|
|
209
|
+
self.session.mount("https://", adapter)
|
|
210
|
+
self.session.mount("http://", adapter)
|
|
211
|
+
self.session.headers.update(
|
|
212
|
+
{
|
|
213
|
+
"Authorization": f"Bearer {api_key}",
|
|
214
|
+
"Content-Type": "application/json",
|
|
215
|
+
"Accept": "application/json",
|
|
216
|
+
"X-SDK-Version": "1.0.0",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _url(self, path: str) -> str:
|
|
221
|
+
return urljoin(self.base_url + "/", path.lstrip("/"))
|
|
222
|
+
|
|
223
|
+
def _raise(self, response: requests.Response) -> None:
|
|
224
|
+
if response.status_code == 401:
|
|
225
|
+
raise AuthError("Invalid API key.")
|
|
226
|
+
if response.status_code == 404:
|
|
227
|
+
raise NotFoundError(response.text)
|
|
228
|
+
if response.status_code == 429:
|
|
229
|
+
raise RateLimitError("Rate limit hit. Retry after a moment.")
|
|
230
|
+
response.raise_for_status()
|
|
231
|
+
|
|
232
|
+
def get(self, path: str, **params: Any) -> dict:
|
|
233
|
+
response = self.session.get(self._url(path), params=params, timeout=self.timeout)
|
|
234
|
+
self._raise(response)
|
|
235
|
+
return response.json()
|
|
236
|
+
|
|
237
|
+
def post(self, path: str, body: dict) -> dict:
|
|
238
|
+
response = self.session.post(self._url(path), json=body, timeout=self.timeout)
|
|
239
|
+
self._raise(response)
|
|
240
|
+
return response.json()
|
|
241
|
+
|
|
242
|
+
def put(self, path: str, body: dict) -> dict:
|
|
243
|
+
response = self.session.put(self._url(path), json=body, timeout=self.timeout)
|
|
244
|
+
self._raise(response)
|
|
245
|
+
return response.json()
|
|
246
|
+
|
|
247
|
+
def delete(self, path: str) -> None:
|
|
248
|
+
response = self.session.delete(self._url(path), timeout=self.timeout)
|
|
249
|
+
self._raise(response)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _Resource(Generic[RecordT]):
|
|
253
|
+
"""Base CRUD and bulk helpers for a typed API resource."""
|
|
254
|
+
|
|
255
|
+
_path: str
|
|
256
|
+
_model: _RecordFactory[RecordT]
|
|
257
|
+
|
|
258
|
+
def __init__(self, http: _HttpClient):
|
|
259
|
+
self._http = http
|
|
260
|
+
|
|
261
|
+
def create(self, record: RecordT) -> RecordT:
|
|
262
|
+
"""Create a record on the remote API."""
|
|
263
|
+
data = self._http.post(self._path, record.to_dict())
|
|
264
|
+
return self._model.from_dict(data)
|
|
265
|
+
|
|
266
|
+
def get(self, record_id: str) -> RecordT:
|
|
267
|
+
"""Fetch a record by ID."""
|
|
268
|
+
data = self._http.get(f"{self._path}/{record_id}")
|
|
269
|
+
return self._model.from_dict(data)
|
|
270
|
+
|
|
271
|
+
def update(self, record: RecordT) -> RecordT:
|
|
272
|
+
"""Update a record by ID."""
|
|
273
|
+
if not record.id:
|
|
274
|
+
raise ContackdError("Record has no id — cannot update.")
|
|
275
|
+
data = self._http.put(f"{self._path}/{record.id}", record.to_dict())
|
|
276
|
+
return self._model.from_dict(data)
|
|
277
|
+
|
|
278
|
+
def delete(self, record_id: str) -> None:
|
|
279
|
+
"""Delete a record by ID."""
|
|
280
|
+
self._http.delete(f"{self._path}/{record_id}")
|
|
281
|
+
|
|
282
|
+
def list(self, page: int = 1, per_page: int = 100, **filters: Any) -> List[RecordT]:
|
|
283
|
+
"""List records, returning typed model instances."""
|
|
284
|
+
data = self._http.get(self._path, page=page, per_page=per_page, **filters)
|
|
285
|
+
items = data.get("results", data) if isinstance(data, dict) else data
|
|
286
|
+
return [self._model.from_dict(item) for item in items]
|
|
287
|
+
|
|
288
|
+
def upsert(self, record: RecordT) -> RecordT:
|
|
289
|
+
"""Create or update a record using the natural key (email)."""
|
|
290
|
+
return self._model.from_dict(self._http.post(f"{self._path}/upsert", record.to_dict()))
|
|
291
|
+
|
|
292
|
+
def bulk_create(self, records: List[RecordT]) -> List[RecordT]:
|
|
293
|
+
"""Create multiple records in a single request."""
|
|
294
|
+
payload = {"records": [record.to_dict() for record in records]}
|
|
295
|
+
data = self._http.post(f"{self._path}/bulk", payload)
|
|
296
|
+
return [self._model.from_dict(item) for item in data.get("created", [])]
|
|
297
|
+
|
|
298
|
+
def bulk_upsert(self, records: List[RecordT], chunk_size: int = 100) -> "BulkResult":
|
|
299
|
+
"""Upsert records in chunks to stay within API limits."""
|
|
300
|
+
result = BulkResult()
|
|
301
|
+
for index in range(0, len(records), chunk_size):
|
|
302
|
+
chunk = records[index : index + chunk_size]
|
|
303
|
+
payload = {"records": [record.to_dict() for record in chunk]}
|
|
304
|
+
response = self._http.post(f"{self._path}/bulk/upsert", payload)
|
|
305
|
+
result.created.extend(self._model.from_dict(item) for item in response.get("created", []))
|
|
306
|
+
result.updated.extend(self._model.from_dict(item) for item in response.get("updated", []))
|
|
307
|
+
result.errors.extend(response.get("errors", []))
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
def bulk_delete(self, record_ids: List[str]) -> None:
|
|
311
|
+
"""Delete multiple records in a single request."""
|
|
312
|
+
self._http.post(f"{self._path}/bulk/delete", {"ids": record_ids})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class BulkResult:
|
|
317
|
+
"""Container for bulk operation outcomes."""
|
|
318
|
+
|
|
319
|
+
created: List[Any] = field(default_factory=list)
|
|
320
|
+
updated: List[Any] = field(default_factory=list)
|
|
321
|
+
errors: List[Any] = field(default_factory=list)
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def total(self) -> int:
|
|
325
|
+
"""Return the number of successfully processed records."""
|
|
326
|
+
return len(self.created) + len(self.updated)
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
return (
|
|
330
|
+
f"BulkResult(created={len(self.created)}, "
|
|
331
|
+
f"updated={len(self.updated)}, errors={len(self.errors)})"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class ContactsResource(_Resource[Contact]):
|
|
336
|
+
"""Typed resource wrapper for contacts."""
|
|
337
|
+
|
|
338
|
+
_path = "contacts"
|
|
339
|
+
_model = Contact
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class LeadsResource(_Resource[Lead]):
|
|
343
|
+
"""Typed resource wrapper for leads."""
|
|
344
|
+
|
|
345
|
+
_path = "leads"
|
|
346
|
+
_model = Lead
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ProspectsResource(_Resource[Prospect]):
|
|
350
|
+
"""Typed resource wrapper for prospects."""
|
|
351
|
+
|
|
352
|
+
_path = "prospects"
|
|
353
|
+
_model = Prospect
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass
|
|
357
|
+
class SyncResult:
|
|
358
|
+
"""Outcome summary for a bidirectional sync operation."""
|
|
359
|
+
|
|
360
|
+
pushed: List[Any] = field(default_factory=list)
|
|
361
|
+
skipped: List[Any] = field(default_factory=list)
|
|
362
|
+
resolved: List[Any] = field(default_factory=list)
|
|
363
|
+
remote_only: List[Any] = field(default_factory=list)
|
|
364
|
+
errors: List[Any] = field(default_factory=list)
|
|
365
|
+
bulk_result: Optional[BulkResult] = None
|
|
366
|
+
|
|
367
|
+
def __repr__(self) -> str:
|
|
368
|
+
return (
|
|
369
|
+
f"SyncResult(pushed={len(self.pushed)}, skipped={len(self.skipped)}, "
|
|
370
|
+
f"resolved={len(self.resolved)}, remote_only={len(self.remote_only)}, "
|
|
371
|
+
f"errors={len(self.errors)})"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class SyncEngine(Generic[RecordT]):
|
|
376
|
+
"""Bidirectional sync engine for local records and the remote API."""
|
|
377
|
+
|
|
378
|
+
def __init__(
|
|
379
|
+
self,
|
|
380
|
+
resource: _Resource[RecordT],
|
|
381
|
+
conflict_strategy: ConflictStrategy = "remote",
|
|
382
|
+
on_conflict: Optional[Callable[[SyncConflictError], RecordT]] = None,
|
|
383
|
+
):
|
|
384
|
+
self.resource = resource
|
|
385
|
+
self.conflict_strategy = conflict_strategy
|
|
386
|
+
self.on_conflict = on_conflict
|
|
387
|
+
|
|
388
|
+
def push(self, records: List[RecordT], chunk_size: int = 100) -> BulkResult:
|
|
389
|
+
"""Push local records to the remote API using bulk upsert."""
|
|
390
|
+
return self.resource.bulk_upsert(records, chunk_size=chunk_size)
|
|
391
|
+
|
|
392
|
+
def pull(self, **filters: Any) -> List[RecordT]:
|
|
393
|
+
"""Fetch all remote records with auto-pagination."""
|
|
394
|
+
all_records: List[RecordT] = []
|
|
395
|
+
page = 1
|
|
396
|
+
while True:
|
|
397
|
+
page_data = self.resource.list(page=page, per_page=100, **filters)
|
|
398
|
+
if not page_data:
|
|
399
|
+
break
|
|
400
|
+
all_records.extend(page_data)
|
|
401
|
+
if len(page_data) < 100:
|
|
402
|
+
break
|
|
403
|
+
page += 1
|
|
404
|
+
return all_records
|
|
405
|
+
|
|
406
|
+
def sync(self, local_records: List[RecordT], **filters: Any) -> SyncResult:
|
|
407
|
+
"""Run a full bidirectional sync against the remote API."""
|
|
408
|
+
result = SyncResult()
|
|
409
|
+
remote_records = self.pull(**filters)
|
|
410
|
+
remote_by_email: Dict[str, RecordT] = {record.email: record for record in remote_records}
|
|
411
|
+
local_emails = {record.email for record in local_records}
|
|
412
|
+
|
|
413
|
+
to_push: List[RecordT] = []
|
|
414
|
+
|
|
415
|
+
for local in local_records:
|
|
416
|
+
remote = remote_by_email.get(local.email)
|
|
417
|
+
|
|
418
|
+
if remote is None:
|
|
419
|
+
to_push.append(local)
|
|
420
|
+
result.pushed.append(local)
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
if local.fingerprint() == remote.fingerprint():
|
|
424
|
+
result.skipped.append(local)
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
winner = self._resolve(local, remote)
|
|
428
|
+
if winner is not None:
|
|
429
|
+
to_push.append(winner)
|
|
430
|
+
result.resolved.append((local, remote, winner))
|
|
431
|
+
|
|
432
|
+
if to_push:
|
|
433
|
+
bulk = self.resource.bulk_upsert(to_push)
|
|
434
|
+
result.bulk_result = bulk
|
|
435
|
+
result.errors.extend(bulk.errors)
|
|
436
|
+
|
|
437
|
+
result.remote_only = [record for record in remote_records if record.email not in local_emails]
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
def _resolve(self, local: RecordT, remote: RecordT) -> Optional[RecordT]:
|
|
441
|
+
"""Resolve a record conflict according to the configured strategy."""
|
|
442
|
+
error = SyncConflictError(
|
|
443
|
+
type(local).__name__, local.id or local.email, local.to_dict(), remote.to_dict()
|
|
444
|
+
)
|
|
445
|
+
if self.on_conflict:
|
|
446
|
+
return self.on_conflict(error)
|
|
447
|
+
if self.conflict_strategy == "local":
|
|
448
|
+
return local
|
|
449
|
+
if self.conflict_strategy == "remote":
|
|
450
|
+
return None
|
|
451
|
+
raise error
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class ContackdClient:
|
|
455
|
+
"""Primary entry point for the Contackd Sync SDK."""
|
|
456
|
+
|
|
457
|
+
def __init__(
|
|
458
|
+
self,
|
|
459
|
+
api_key: str,
|
|
460
|
+
base_url: str,
|
|
461
|
+
timeout: int = 30,
|
|
462
|
+
conflict_strategy: ConflictStrategy = "remote",
|
|
463
|
+
):
|
|
464
|
+
self._http = _HttpClient(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
465
|
+
self.conflict_strategy = conflict_strategy
|
|
466
|
+
|
|
467
|
+
self.contacts = ContactsResource(self._http)
|
|
468
|
+
self.leads = LeadsResource(self._http)
|
|
469
|
+
self.prospects = ProspectsResource(self._http)
|
|
470
|
+
|
|
471
|
+
def sync_contacts(
|
|
472
|
+
self,
|
|
473
|
+
local_records: List[Contact],
|
|
474
|
+
conflict_strategy: Optional[ConflictStrategy] = None,
|
|
475
|
+
on_conflict: Optional[Callable[[SyncConflictError], Contact]] = None,
|
|
476
|
+
**filters: Any,
|
|
477
|
+
) -> SyncResult:
|
|
478
|
+
"""Synchronize contacts between local state and the remote API."""
|
|
479
|
+
engine = SyncEngine(
|
|
480
|
+
self.contacts,
|
|
481
|
+
conflict_strategy=conflict_strategy or self.conflict_strategy,
|
|
482
|
+
on_conflict=on_conflict,
|
|
483
|
+
)
|
|
484
|
+
return engine.sync(local_records, **filters)
|
|
485
|
+
|
|
486
|
+
def sync_leads(
|
|
487
|
+
self,
|
|
488
|
+
local_records: List[Lead],
|
|
489
|
+
conflict_strategy: Optional[ConflictStrategy] = None,
|
|
490
|
+
on_conflict: Optional[Callable[[SyncConflictError], Lead]] = None,
|
|
491
|
+
**filters: Any,
|
|
492
|
+
) -> SyncResult:
|
|
493
|
+
"""Synchronize leads between local state and the remote API."""
|
|
494
|
+
engine = SyncEngine(
|
|
495
|
+
self.leads,
|
|
496
|
+
conflict_strategy=conflict_strategy or self.conflict_strategy,
|
|
497
|
+
on_conflict=on_conflict,
|
|
498
|
+
)
|
|
499
|
+
return engine.sync(local_records, **filters)
|
|
500
|
+
|
|
501
|
+
def sync_prospects(
|
|
502
|
+
self,
|
|
503
|
+
local_records: List[Prospect],
|
|
504
|
+
conflict_strategy: Optional[ConflictStrategy] = None,
|
|
505
|
+
on_conflict: Optional[Callable[[SyncConflictError], Prospect]] = None,
|
|
506
|
+
**filters: Any,
|
|
507
|
+
) -> SyncResult:
|
|
508
|
+
"""Synchronize prospects between local state and the remote API."""
|
|
509
|
+
engine = SyncEngine(
|
|
510
|
+
self.prospects,
|
|
511
|
+
conflict_strategy=conflict_strategy or self.conflict_strategy,
|
|
512
|
+
on_conflict=on_conflict,
|
|
513
|
+
)
|
|
514
|
+
return engine.sync(local_records, **filters)
|
|
515
|
+
|
|
516
|
+
def ping(self) -> bool:
|
|
517
|
+
"""Return ``True`` when the health endpoint responds successfully."""
|
|
518
|
+
try:
|
|
519
|
+
self._http.get("health")
|
|
520
|
+
return True
|
|
521
|
+
except ContackdError:
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = [
|
|
526
|
+
"AuthError",
|
|
527
|
+
"BulkResult",
|
|
528
|
+
"ConflictStrategy",
|
|
529
|
+
"Contact",
|
|
530
|
+
"ContactsResource",
|
|
531
|
+
"ContackdClient",
|
|
532
|
+
"ContackdError",
|
|
533
|
+
"Lead",
|
|
534
|
+
"LeadStatus",
|
|
535
|
+
"LeadsResource",
|
|
536
|
+
"NotFoundError",
|
|
537
|
+
"PipelineStage",
|
|
538
|
+
"Prospect",
|
|
539
|
+
"ProspectsResource",
|
|
540
|
+
"RateLimitError",
|
|
541
|
+
"RecordType",
|
|
542
|
+
"SyncConflictError",
|
|
543
|
+
"SyncEngine",
|
|
544
|
+
"SyncResult",
|
|
545
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contackd-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Contackd Sync
|
|
5
|
+
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
Requires-Dist: urllib3>=2.0.0
|
|
10
|
+
|
|
11
|
+
# contackd-sync
|
|
12
|
+
|
|
13
|
+
Python SDK for the Contackd Sync API.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install requests urllib3
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install the package from this repository once published as a build artifact.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from contackd_sync import ContackdClient, Contact
|
|
27
|
+
|
|
28
|
+
client = ContackdClient(
|
|
29
|
+
api_key="sk-your-key",
|
|
30
|
+
base_url="https://api.yourapp.com/v1",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
contact = client.contacts.upsert(Contact(email="alice@example.com", first_name="Alice"))
|
|
34
|
+
print(contact)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Public API
|
|
38
|
+
|
|
39
|
+
The package exports the main client, typed resource wrappers, models, sync helpers, and exceptions from `contackd_sync`.
|
|
40
|
+
|
|
41
|
+
- `ContackdClient`
|
|
42
|
+
- `ContactsResource`, `LeadsResource`, `ProspectsResource`
|
|
43
|
+
- `Contact`, `Lead`, `Prospect`
|
|
44
|
+
- `LeadStatus`, `PipelineStage`
|
|
45
|
+
- `SyncEngine`, `SyncResult`, `BulkResult`
|
|
46
|
+
- `ContackdError` and specialized exceptions
|
|
47
|
+
|
|
48
|
+
## API Key Provisioning
|
|
49
|
+
|
|
50
|
+
Create and revoke SDK keys from backend auth endpoints:
|
|
51
|
+
|
|
52
|
+
1. Create key (JWT required):
|
|
53
|
+
```bash
|
|
54
|
+
curl -X POST http://localhost:8000/api/v1/auth/api-keys \
|
|
55
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
|
56
|
+
-H "Content-Type: application/json" \
|
|
57
|
+
-d '{"name":"py-sdk"}'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Delete/revoke key:
|
|
61
|
+
```bash
|
|
62
|
+
curl -X DELETE http://localhost:8000/api/v1/auth/api-keys/<API_KEY_ID> \
|
|
63
|
+
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use returned `api_key` as `api_key` when creating `ContackdClient`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
contackd_sync/__init__.py
|
|
4
|
+
contackd_sync/client.py
|
|
5
|
+
contackd_sync.egg-info/PKG-INFO
|
|
6
|
+
contackd_sync.egg-info/SOURCES.txt
|
|
7
|
+
contackd_sync.egg-info/dependency_links.txt
|
|
8
|
+
contackd_sync.egg-info/requires.txt
|
|
9
|
+
contackd_sync.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
contackd_sync
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contackd-sync"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Contackd Sync"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests>=2.31.0",
|
|
13
|
+
"urllib3>=2.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://example.com/contackd-sync"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["."]
|
|
21
|
+
include = ["contackd_sync*"]
|