contackd-sync 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contackd-sync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for Contackd Sync
5
5
  Project-URL: Homepage, https://example.com/contackd-sync
6
6
  Requires-Python: >=3.10
@@ -10,7 +10,7 @@ Requires-Dist: urllib3>=2.0.0
10
10
 
11
11
  # contackd-sync (Python)
12
12
 
13
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
13
+ Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
14
14
 
15
15
  ## Install
16
16
 
@@ -80,6 +80,52 @@ print(len(result.created), len(result.updated), result.errors)
80
80
  page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
81
81
  ```
82
82
 
83
+ ## Sync Businesses Before Their Contacts
84
+
85
+ Create or update businesses first so contacts can reference the returned CRM
86
+ business IDs. Business synchronization uses `external_business_id` as its
87
+ primary natural key and falls back to the normalized business name.
88
+
89
+ ```python
90
+ from contackd_sync import Business, Contact, ContackdClient
91
+
92
+ client = ContackdClient(
93
+ api_key="sk_live_...",
94
+ base_url="https://api.yourapp.com/api/v1",
95
+ )
96
+
97
+ business_result = client.businesses.bulk_upsert(
98
+ [
99
+ Business(
100
+ name="Default Facility",
101
+ external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
102
+ stage="customer",
103
+ status="active",
104
+ tags=["FACILITY"],
105
+ custom={"org_code": "FACILITYBILLS"},
106
+ )
107
+ ]
108
+ )
109
+ business = [*business_result.created, *business_result.updated][0]
110
+
111
+ contact_result = client.contacts.bulk_upsert(
112
+ [
113
+ Contact(
114
+ email="resident@example.com",
115
+ first_name="Resident",
116
+ external_contact_id="resident-1",
117
+ business_id=business.id,
118
+ tags=["RESIDENT"],
119
+ custom={"role": "resident"},
120
+ )
121
+ ]
122
+ )
123
+ ```
124
+
125
+ Callers should merge duplicate source rows before sending them. For a person
126
+ belonging to multiple estates, keep the complete estate list in `custom` and
127
+ use `business_id` for the single primary CRM relationship.
128
+
83
129
  ## API Key Setup
84
130
 
85
131
  Contact support@dalmlabs.com for the API key and base domain.
@@ -94,12 +140,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
94
140
  - `GET /tags/{id}`
95
141
  - `PUT /tags/{id}`
96
142
  - `DELETE /tags/{id}`
97
- - `POST /contacts|leads|prospects`
98
- - `GET /contacts|leads|prospects/{id}`
99
- - `PUT /contacts|leads|prospects/{id}`
100
- - `DELETE /contacts|leads|prospects/{id}`
101
- - `GET /contacts|leads|prospects`
102
- - `POST /contacts|leads|prospects/upsert`
103
- - `POST /contacts|leads|prospects/bulk`
104
- - `POST /contacts|leads|prospects/bulk/upsert`
105
- - `POST /contacts|leads|prospects/bulk/delete`
143
+ - `POST /contacts|leads|prospects|businesses`
144
+ - `GET /contacts|leads|prospects|businesses/{id}`
145
+ - `PUT /contacts|leads|prospects|businesses/{id}`
146
+ - `DELETE /contacts|leads|prospects|businesses/{id}`
147
+ - `GET /contacts|leads|prospects|businesses`
148
+ - `POST /contacts|leads|prospects|businesses/upsert`
149
+ - `POST /contacts|leads|prospects|businesses/bulk`
150
+ - `POST /contacts|leads|prospects|businesses/bulk/upsert`
151
+ - `POST /contacts|leads|prospects|businesses/bulk/delete`
@@ -0,0 +1,141 @@
1
+ # contackd-sync (Python)
2
+
3
+ Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install contackd-sync
9
+ ```
10
+
11
+ ## 2-Minute Start (Push Customer Details)
12
+
13
+ ```python
14
+ from contackd_sync import ContackdClient, Contact
15
+
16
+ client = ContackdClient(
17
+ api_key="sk_live_...",
18
+ base_url="https://api.yourapp.com/api/v1", # no trailing slash required
19
+ )
20
+
21
+ # Create/update a customer contact by email
22
+ customer = client.contacts.upsert(
23
+ Contact(
24
+ email="jane@acme.com",
25
+ first_name="Jane",
26
+ last_name="Doe",
27
+ phone="+14155550199",
28
+ tags=["customer", "vip"],
29
+ custom={
30
+ "customer_id": "CUST-1001",
31
+ "plan": "pro",
32
+ "country": "US",
33
+ },
34
+ )
35
+ )
36
+
37
+ print(customer)
38
+ ```
39
+
40
+ ## Common Customer Push Patterns
41
+
42
+ ### 1) Push one new customer
43
+
44
+ ```python
45
+ from contackd_sync import Contact
46
+
47
+ created = client.contacts.create(
48
+ Contact(email="new@acme.com", first_name="New", last_name="Customer")
49
+ )
50
+ ```
51
+
52
+ ### 2) Push many customers (bulk upsert)
53
+
54
+ ```python
55
+ from contackd_sync import Contact
56
+
57
+ result = client.contacts.bulk_upsert(
58
+ [
59
+ Contact(email="a@acme.com", first_name="A"),
60
+ Contact(email="b@acme.com", first_name="B", tags=["trial"]),
61
+ ]
62
+ )
63
+
64
+ print(len(result.created), len(result.updated), result.errors)
65
+ ```
66
+
67
+ ### 3) Read/filter customers
68
+
69
+ ```python
70
+ page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
71
+ ```
72
+
73
+ ## Sync Businesses Before Their Contacts
74
+
75
+ Create or update businesses first so contacts can reference the returned CRM
76
+ business IDs. Business synchronization uses `external_business_id` as its
77
+ primary natural key and falls back to the normalized business name.
78
+
79
+ ```python
80
+ from contackd_sync import Business, Contact, ContackdClient
81
+
82
+ client = ContackdClient(
83
+ api_key="sk_live_...",
84
+ base_url="https://api.yourapp.com/api/v1",
85
+ )
86
+
87
+ business_result = client.businesses.bulk_upsert(
88
+ [
89
+ Business(
90
+ name="Default Facility",
91
+ external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
92
+ stage="customer",
93
+ status="active",
94
+ tags=["FACILITY"],
95
+ custom={"org_code": "FACILITYBILLS"},
96
+ )
97
+ ]
98
+ )
99
+ business = [*business_result.created, *business_result.updated][0]
100
+
101
+ contact_result = client.contacts.bulk_upsert(
102
+ [
103
+ Contact(
104
+ email="resident@example.com",
105
+ first_name="Resident",
106
+ external_contact_id="resident-1",
107
+ business_id=business.id,
108
+ tags=["RESIDENT"],
109
+ custom={"role": "resident"},
110
+ )
111
+ ]
112
+ )
113
+ ```
114
+
115
+ Callers should merge duplicate source rows before sending them. For a person
116
+ belonging to multiple estates, keep the complete estate list in `custom` and
117
+ use `business_id` for the single primary CRM relationship.
118
+
119
+ ## API Key Setup
120
+
121
+ Contact support@dalmlabs.com for the API key and base domain.
122
+
123
+ ## Backend Routes Expected by SDK
124
+
125
+ Given `base_url = https://api.yourapp.com/api/v1`:
126
+
127
+ - `GET /health`
128
+ - `POST /tags`
129
+ - `GET /tags`
130
+ - `GET /tags/{id}`
131
+ - `PUT /tags/{id}`
132
+ - `DELETE /tags/{id}`
133
+ - `POST /contacts|leads|prospects|businesses`
134
+ - `GET /contacts|leads|prospects|businesses/{id}`
135
+ - `PUT /contacts|leads|prospects|businesses/{id}`
136
+ - `DELETE /contacts|leads|prospects|businesses/{id}`
137
+ - `GET /contacts|leads|prospects|businesses`
138
+ - `POST /contacts|leads|prospects|businesses/upsert`
139
+ - `POST /contacts|leads|prospects|businesses/bulk`
140
+ - `POST /contacts|leads|prospects|businesses/bulk/upsert`
141
+ - `POST /contacts|leads|prospects|businesses/bulk/delete`
@@ -3,6 +3,8 @@
3
3
  from ._version import __version__
4
4
  from .client import (
5
5
  AuthError,
6
+ Business,
7
+ BusinessesResource,
6
8
  BulkResult,
7
9
  ConflictStrategy,
8
10
  Contact,
@@ -26,6 +28,8 @@ from .client import (
26
28
  __all__ = [
27
29
  "__version__",
28
30
  "AuthError",
31
+ "Business",
32
+ "BusinessesResource",
29
33
  "BulkResult",
30
34
  "ConflictStrategy",
31
35
  "Contact",
@@ -74,6 +74,8 @@ class Contact:
74
74
  first_name: str = ""
75
75
  last_name: str = ""
76
76
  phone: Optional[str] = None
77
+ external_contact_id: Optional[str] = None
78
+ business_id: Optional[str] = None
77
79
  tags: List[str] = field(default_factory=list)
78
80
  custom: Dict[str, Any] = field(default_factory=dict)
79
81
  id: Optional[str] = None
@@ -180,9 +182,49 @@ class Prospect:
180
182
  return hashlib.sha256(payload.encode()).hexdigest()
181
183
 
182
184
 
183
- RecordType = Contact | Lead | Prospect
185
+ @dataclass
186
+ class Business:
187
+ """Business record model used by the Businesses API."""
188
+
189
+ name: str
190
+ external_business_id: Optional[str] = None
191
+ email: Optional[str] = None
192
+ phone: Optional[str] = None
193
+ website: Optional[str] = None
194
+ industry: Optional[str] = None
195
+ address: Optional[str] = None
196
+ notes: Optional[str] = None
197
+ primary_contact_id: Optional[str] = None
198
+ stage: str = "lead"
199
+ status: str = "active"
200
+ estimated_value: Optional[int] = None
201
+ tags: List[str] = field(default_factory=list)
202
+ custom: Dict[str, Any] = field(default_factory=dict)
203
+ id: Optional[str] = None
204
+ updated_at: Optional[str] = None
205
+
206
+ def to_dict(self) -> dict:
207
+ """Serialize the record to a JSON-friendly dictionary."""
208
+ data = asdict(self)
209
+ return {key: value for key, value in data.items() if value is not None}
210
+
211
+ @classmethod
212
+ def from_dict(cls, data: dict) -> "Business":
213
+ """Build a business from API response data."""
214
+ return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
215
+
216
+ def fingerprint(self) -> str:
217
+ """Return a stable SHA-256 digest for change detection."""
218
+ payload = json.dumps(
219
+ {key: value for key, value in self.to_dict().items() if key not in ("id", "updated_at")},
220
+ sort_keys=True,
221
+ )
222
+ return hashlib.sha256(payload.encode()).hexdigest()
223
+
224
+
225
+ RecordType = Contact | Lead | Prospect | Business
184
226
  ConflictStrategy = Literal["local", "remote", "raise"]
185
- RecordT = TypeVar("RecordT", Contact, Lead, Prospect)
227
+ RecordT = TypeVar("RecordT", Contact, Lead, Prospect, Business)
186
228
 
187
229
 
188
230
  class _RecordModel(Protocol):
@@ -199,8 +241,31 @@ class _RecordFactory(Protocol[RecordT]):
199
241
  def from_dict(cls, data: dict) -> RecordT: ...
200
242
 
201
243
 
244
+ def _record_keys(record: _RecordModel) -> List[str]:
245
+ keys: List[str] = []
246
+ if hasattr(record, "external_business_id"):
247
+ external_business_id = getattr(record, "external_business_id", None)
248
+ if external_business_id:
249
+ keys.append(
250
+ f"external_business_id:{str(external_business_id).strip().casefold()}"
251
+ )
252
+ name = getattr(record, "name", None)
253
+ if name:
254
+ keys.append(f"name:{str(name).strip().casefold()}")
255
+ else:
256
+ if record.email:
257
+ keys.append(f"email:{record.email.strip().casefold()}")
258
+ external_thread_id = getattr(record, "external_thread_id", None)
259
+ if external_thread_id:
260
+ keys.append(f"external_thread_id:{str(external_thread_id).strip()}")
261
+ if record.id:
262
+ keys.append(f"id:{record.id}")
263
+ return keys
264
+
265
+
202
266
  def _record_key(record: _RecordModel) -> Optional[str]:
203
- return record.email or getattr(record, "external_thread_id", None) or record.id
267
+ keys = _record_keys(record)
268
+ return keys[0] if keys else None
204
269
 
205
270
 
206
271
  class _HttpClient:
@@ -365,6 +430,13 @@ class ProspectsResource(_Resource[Prospect]):
365
430
  _model = Prospect
366
431
 
367
432
 
433
+ class BusinessesResource(_Resource[Business]):
434
+ """Typed resource wrapper for businesses."""
435
+
436
+ _path = "businesses"
437
+ _model = Business
438
+
439
+
368
440
  @dataclass
369
441
  class SyncResult:
370
442
  """Outcome summary for a bidirectional sync operation."""
@@ -419,19 +491,23 @@ class SyncEngine(Generic[RecordT]):
419
491
  """Run a full bidirectional sync against the remote API."""
420
492
  result = SyncResult()
421
493
  remote_records = self.pull(**filters)
422
- remote_by_key: Dict[str, RecordT] = {
423
- key: record
424
- for record in remote_records
425
- if (key := _record_key(record)) is not None
426
- }
494
+ remote_by_key: Dict[str, RecordT] = {}
495
+ for record in remote_records:
496
+ for key in _record_keys(record):
497
+ remote_by_key.setdefault(key, record)
427
498
  local_keys = {
428
- key for record in local_records if (key := _record_key(record)) is not None
499
+ key
500
+ for record in local_records
501
+ for key in _record_keys(record)
429
502
  }
430
503
 
431
504
  to_push: List[RecordT] = []
432
505
 
433
506
  for local in local_records:
434
- remote = remote_by_key.get(_record_key(local))
507
+ remote = next(
508
+ (remote_by_key[key] for key in _record_keys(local) if key in remote_by_key),
509
+ None,
510
+ )
435
511
 
436
512
  if remote is None:
437
513
  to_push.append(local)
@@ -453,7 +529,9 @@ class SyncEngine(Generic[RecordT]):
453
529
  result.errors.extend(bulk.errors)
454
530
 
455
531
  result.remote_only = [
456
- record for record in remote_records if _record_key(record) not in local_keys
532
+ record
533
+ for record in remote_records
534
+ if not local_keys.intersection(_record_keys(record))
457
535
  ]
458
536
  return result
459
537
 
@@ -487,6 +565,7 @@ class ContackdClient:
487
565
  self.contacts = ContactsResource(self._http)
488
566
  self.leads = LeadsResource(self._http)
489
567
  self.prospects = ProspectsResource(self._http)
568
+ self.businesses = BusinessesResource(self._http)
490
569
 
491
570
  def sync_contacts(
492
571
  self,
@@ -533,6 +612,21 @@ class ContackdClient:
533
612
  )
534
613
  return engine.sync(local_records, **filters)
535
614
 
615
+ def sync_businesses(
616
+ self,
617
+ local_records: List[Business],
618
+ conflict_strategy: Optional[ConflictStrategy] = None,
619
+ on_conflict: Optional[Callable[[SyncConflictError], Business]] = None,
620
+ **filters: Any,
621
+ ) -> SyncResult:
622
+ """Synchronize businesses between local state and the remote API."""
623
+ engine = SyncEngine(
624
+ self.businesses,
625
+ conflict_strategy=conflict_strategy or self.conflict_strategy,
626
+ on_conflict=on_conflict,
627
+ )
628
+ return engine.sync(local_records, **filters)
629
+
536
630
  def ping(self) -> bool:
537
631
  """Return ``True`` when the health endpoint responds successfully."""
538
632
  try:
@@ -544,6 +638,8 @@ class ContackdClient:
544
638
 
545
639
  __all__ = [
546
640
  "AuthError",
641
+ "Business",
642
+ "BusinessesResource",
547
643
  "BulkResult",
548
644
  "ConflictStrategy",
549
645
  "Contact",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contackd-sync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for Contackd Sync
5
5
  Project-URL: Homepage, https://example.com/contackd-sync
6
6
  Requires-Python: >=3.10
@@ -10,7 +10,7 @@ Requires-Dist: urllib3>=2.0.0
10
10
 
11
11
  # contackd-sync (Python)
12
12
 
13
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
13
+ Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
14
14
 
15
15
  ## Install
16
16
 
@@ -80,6 +80,52 @@ print(len(result.created), len(result.updated), result.errors)
80
80
  page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
81
81
  ```
82
82
 
83
+ ## Sync Businesses Before Their Contacts
84
+
85
+ Create or update businesses first so contacts can reference the returned CRM
86
+ business IDs. Business synchronization uses `external_business_id` as its
87
+ primary natural key and falls back to the normalized business name.
88
+
89
+ ```python
90
+ from contackd_sync import Business, Contact, ContackdClient
91
+
92
+ client = ContackdClient(
93
+ api_key="sk_live_...",
94
+ base_url="https://api.yourapp.com/api/v1",
95
+ )
96
+
97
+ business_result = client.businesses.bulk_upsert(
98
+ [
99
+ Business(
100
+ name="Default Facility",
101
+ external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
102
+ stage="customer",
103
+ status="active",
104
+ tags=["FACILITY"],
105
+ custom={"org_code": "FACILITYBILLS"},
106
+ )
107
+ ]
108
+ )
109
+ business = [*business_result.created, *business_result.updated][0]
110
+
111
+ contact_result = client.contacts.bulk_upsert(
112
+ [
113
+ Contact(
114
+ email="resident@example.com",
115
+ first_name="Resident",
116
+ external_contact_id="resident-1",
117
+ business_id=business.id,
118
+ tags=["RESIDENT"],
119
+ custom={"role": "resident"},
120
+ )
121
+ ]
122
+ )
123
+ ```
124
+
125
+ Callers should merge duplicate source rows before sending them. For a person
126
+ belonging to multiple estates, keep the complete estate list in `custom` and
127
+ use `business_id` for the single primary CRM relationship.
128
+
83
129
  ## API Key Setup
84
130
 
85
131
  Contact support@dalmlabs.com for the API key and base domain.
@@ -94,12 +140,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
94
140
  - `GET /tags/{id}`
95
141
  - `PUT /tags/{id}`
96
142
  - `DELETE /tags/{id}`
97
- - `POST /contacts|leads|prospects`
98
- - `GET /contacts|leads|prospects/{id}`
99
- - `PUT /contacts|leads|prospects/{id}`
100
- - `DELETE /contacts|leads|prospects/{id}`
101
- - `GET /contacts|leads|prospects`
102
- - `POST /contacts|leads|prospects/upsert`
103
- - `POST /contacts|leads|prospects/bulk`
104
- - `POST /contacts|leads|prospects/bulk/upsert`
105
- - `POST /contacts|leads|prospects/bulk/delete`
143
+ - `POST /contacts|leads|prospects|businesses`
144
+ - `GET /contacts|leads|prospects|businesses/{id}`
145
+ - `PUT /contacts|leads|prospects|businesses/{id}`
146
+ - `DELETE /contacts|leads|prospects|businesses/{id}`
147
+ - `GET /contacts|leads|prospects|businesses`
148
+ - `POST /contacts|leads|prospects|businesses/upsert`
149
+ - `POST /contacts|leads|prospects|businesses/bulk`
150
+ - `POST /contacts|leads|prospects|businesses/bulk/upsert`
151
+ - `POST /contacts|leads|prospects|businesses/bulk/delete`
@@ -8,4 +8,5 @@ contackd_sync.egg-info/SOURCES.txt
8
8
  contackd_sync.egg-info/dependency_links.txt
9
9
  contackd_sync.egg-info/requires.txt
10
10
  contackd_sync.egg-info/top_level.txt
11
+ tests/test_business_sync.py
11
12
  tests/test_client.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contackd-sync"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for Contackd Sync"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import contackd_sync
6
+ from contackd_sync import Business, Contact, ContackdClient, SyncEngine
7
+
8
+
9
+ class RecordingHttpClient:
10
+ def __init__(self, responses: list[dict[str, Any]] | None = None) -> None:
11
+ self.responses = list(responses or [])
12
+ self.posts: list[tuple[str, dict[str, Any]]] = []
13
+
14
+ def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
15
+ self.posts.append((path, body))
16
+ return self.responses.pop(0) if self.responses else {}
17
+
18
+
19
+ class InMemoryBusinessResource:
20
+ def __init__(self, records: list[Business]) -> None:
21
+ self.records = records
22
+ self.pushed: list[Business] = []
23
+
24
+ def list(self, page: int = 1, per_page: int = 100, **filters: Any) -> list[Business]:
25
+ del per_page, filters
26
+ return self.records if page == 1 else []
27
+
28
+ def bulk_upsert(self, records: list[Business], chunk_size: int = 100) -> contackd_sync.BulkResult:
29
+ del chunk_size
30
+ self.pushed.extend(records)
31
+ return contackd_sync.BulkResult(updated=list(records))
32
+
33
+
34
+ def test_contact_serializes_source_and_business_ids() -> None:
35
+ contact = Contact(
36
+ email="resident@example.com",
37
+ external_contact_id="resident-1",
38
+ business_id="business-1",
39
+ )
40
+
41
+ assert contact.to_dict()["external_contact_id"] == "resident-1"
42
+ assert contact.to_dict()["business_id"] == "business-1"
43
+
44
+
45
+ def test_business_serializes_facility_fields() -> None:
46
+ business = contackd_sync.Business(
47
+ name="Default Facility",
48
+ external_business_id="estate-1",
49
+ stage="customer",
50
+ status="active",
51
+ tags=["FACILITY"],
52
+ custom={"org_code": "FACILITYBILLS"},
53
+ )
54
+
55
+ assert business.to_dict() == {
56
+ "name": "Default Facility",
57
+ "external_business_id": "estate-1",
58
+ "stage": "customer",
59
+ "status": "active",
60
+ "tags": ["FACILITY"],
61
+ "custom": {"org_code": "FACILITYBILLS"},
62
+ }
63
+
64
+
65
+ def test_business_resource_bulk_upserts_in_chunks() -> None:
66
+ http = RecordingHttpClient(
67
+ [
68
+ {"created": [{"id": "business-1", "name": "Estate 1", "external_business_id": "estate-1"}]},
69
+ {"updated": [{"id": "business-2", "name": "Estate 2", "external_business_id": "estate-2"}]},
70
+ ]
71
+ )
72
+ resource_type = getattr(contackd_sync, "BusinessesResource")
73
+ resource = resource_type(http)
74
+ records = [
75
+ Business(name="Estate 1", external_business_id="estate-1"),
76
+ Business(name="Estate 2", external_business_id="estate-2"),
77
+ ]
78
+
79
+ result = resource.bulk_upsert(records, chunk_size=1)
80
+
81
+ assert [path for path, _ in http.posts] == ["businesses/bulk/upsert", "businesses/bulk/upsert"]
82
+ assert result.total == 2
83
+ assert [record.id for record in result.created] == ["business-1"]
84
+ assert [record.id for record in result.updated] == ["business-2"]
85
+
86
+
87
+ def test_business_sync_matches_by_external_business_id() -> None:
88
+ remote = Business(
89
+ id="business-1",
90
+ name="Old Facility Name",
91
+ external_business_id="estate-1",
92
+ )
93
+ other_remote = Business(
94
+ id="business-2",
95
+ name="Other Facility",
96
+ external_business_id="estate-2",
97
+ )
98
+ local = Business(
99
+ name="Renamed Facility",
100
+ external_business_id="estate-1",
101
+ )
102
+ resource = InMemoryBusinessResource([remote, other_remote])
103
+ engine = SyncEngine(resource, conflict_strategy="local") # type: ignore[arg-type]
104
+
105
+ result = engine.sync([local])
106
+
107
+ assert result.pushed == []
108
+ assert len(result.resolved) == 1
109
+ assert result.resolved[0][1] is remote
110
+ assert resource.pushed == [local]
111
+ assert result.remote_only == [other_remote]
112
+
113
+
114
+ def test_business_sync_falls_back_to_name_when_remote_has_no_external_id() -> None:
115
+ remote = Business(id="business-1", name="Default Facility")
116
+ local = Business(name=" default facility ", external_business_id="estate-1")
117
+ resource = InMemoryBusinessResource([remote])
118
+ engine = SyncEngine(resource, conflict_strategy="local") # type: ignore[arg-type]
119
+
120
+ result = engine.sync([local])
121
+
122
+ assert result.pushed == []
123
+ assert len(result.resolved) == 1
124
+ assert result.resolved[0][1] is remote
125
+ assert result.remote_only == []
126
+
127
+
128
+ def test_client_exposes_business_resource() -> None:
129
+ client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
130
+
131
+ resource_type = getattr(contackd_sync, "BusinessesResource")
132
+ assert isinstance(client.businesses, resource_type)
133
+
134
+
135
+ def test_client_syncs_businesses_with_requested_conflict_strategy() -> None:
136
+ remote = Business(id="business-1", name="Old Name", external_business_id="estate-1")
137
+ local = Business(name="New Name", external_business_id="estate-1")
138
+ resource = InMemoryBusinessResource([remote])
139
+ client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
140
+ client.businesses = resource # type: ignore[assignment]
141
+
142
+ result = client.sync_businesses([local], conflict_strategy="local")
143
+
144
+ assert resource.pushed == [local]
145
+ assert len(result.resolved) == 1
146
+
147
+
148
+ def test_client_reports_the_installed_sdk_version() -> None:
149
+ client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
150
+
151
+ assert client._http.session.headers["X-SDK-Version"] == contackd_sync.__version__
@@ -1,95 +0,0 @@
1
- # contackd-sync (Python)
2
-
3
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install contackd-sync
9
- ```
10
-
11
- ## 2-Minute Start (Push Customer Details)
12
-
13
- ```python
14
- from contackd_sync import ContackdClient, Contact
15
-
16
- client = ContackdClient(
17
- api_key="sk_live_...",
18
- base_url="https://api.yourapp.com/api/v1", # no trailing slash required
19
- )
20
-
21
- # Create/update a customer contact by email
22
- customer = client.contacts.upsert(
23
- Contact(
24
- email="jane@acme.com",
25
- first_name="Jane",
26
- last_name="Doe",
27
- phone="+14155550199",
28
- tags=["customer", "vip"],
29
- custom={
30
- "customer_id": "CUST-1001",
31
- "plan": "pro",
32
- "country": "US",
33
- },
34
- )
35
- )
36
-
37
- print(customer)
38
- ```
39
-
40
- ## Common Customer Push Patterns
41
-
42
- ### 1) Push one new customer
43
-
44
- ```python
45
- from contackd_sync import Contact
46
-
47
- created = client.contacts.create(
48
- Contact(email="new@acme.com", first_name="New", last_name="Customer")
49
- )
50
- ```
51
-
52
- ### 2) Push many customers (bulk upsert)
53
-
54
- ```python
55
- from contackd_sync import Contact
56
-
57
- result = client.contacts.bulk_upsert(
58
- [
59
- Contact(email="a@acme.com", first_name="A"),
60
- Contact(email="b@acme.com", first_name="B", tags=["trial"]),
61
- ]
62
- )
63
-
64
- print(len(result.created), len(result.updated), result.errors)
65
- ```
66
-
67
- ### 3) Read/filter customers
68
-
69
- ```python
70
- page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
71
- ```
72
-
73
- ## API Key Setup
74
-
75
- Contact support@dalmlabs.com for the API key and base domain.
76
-
77
- ## Backend Routes Expected by SDK
78
-
79
- Given `base_url = https://api.yourapp.com/api/v1`:
80
-
81
- - `GET /health`
82
- - `POST /tags`
83
- - `GET /tags`
84
- - `GET /tags/{id}`
85
- - `PUT /tags/{id}`
86
- - `DELETE /tags/{id}`
87
- - `POST /contacts|leads|prospects`
88
- - `GET /contacts|leads|prospects/{id}`
89
- - `PUT /contacts|leads|prospects/{id}`
90
- - `DELETE /contacts|leads|prospects/{id}`
91
- - `GET /contacts|leads|prospects`
92
- - `POST /contacts|leads|prospects/upsert`
93
- - `POST /contacts|leads|prospects/bulk`
94
- - `POST /contacts|leads|prospects/bulk/upsert`
95
- - `POST /contacts|leads|prospects/bulk/delete`
File without changes