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.
@@ -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,2 @@
1
+ requests>=2.31.0
2
+ urllib3>=2.0.0
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+