threecommon 0.3.0__tar.gz → 0.4.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.
Files changed (42) hide show
  1. {threecommon-0.3.0 → threecommon-0.4.0}/CHANGELOG.md +18 -0
  2. {threecommon-0.3.0 → threecommon-0.4.0}/PKG-INFO +1 -1
  3. {threecommon-0.3.0 → threecommon-0.4.0}/pyproject.toml +1 -1
  4. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/client.py +9 -0
  5. threecommon-0.4.0/src/threecommon/contacts/__init__.py +58 -0
  6. threecommon-0.4.0/src/threecommon/contacts/service.py +331 -0
  7. threecommon-0.4.0/src/threecommon/contacts/types.py +310 -0
  8. {threecommon-0.3.0 → threecommon-0.4.0}/.gitignore +0 -0
  9. {threecommon-0.3.0 → threecommon-0.4.0}/LICENSE +0 -0
  10. {threecommon-0.3.0 → threecommon-0.4.0}/README.md +0 -0
  11. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/__init__.py +0 -0
  12. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/__init__.py +0 -0
  13. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/headers.py +0 -0
  14. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/http_client.py +0 -0
  15. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/parse.py +0 -0
  16. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/retry.py +0 -0
  17. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/telemetry.py +0 -0
  18. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_core/url.py +0 -0
  19. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_generated/__init__.py +0 -0
  20. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/_generated/models.py +0 -0
  21. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/api_version.py +0 -0
  22. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/config.py +0 -0
  23. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/errors/__init__.py +0 -0
  24. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/errors/base.py +0 -0
  25. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/errors/classes.py +0 -0
  26. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/events/__init__.py +0 -0
  27. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/events/service.py +0 -0
  28. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/events/types.py +0 -0
  29. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/filters/__init__.py +0 -0
  30. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/filters/builder.py +0 -0
  31. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/filters/types.py +0 -0
  32. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/helpers.py +0 -0
  33. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/invoices/__init__.py +0 -0
  34. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/invoices/service.py +0 -0
  35. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/invoices/types.py +0 -0
  36. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/pagination/__init__.py +0 -0
  37. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/pagination/auto_paginator.py +0 -0
  38. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/py.typed +0 -0
  39. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/subscriptions/__init__.py +0 -0
  40. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/subscriptions/service.py +0 -0
  41. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/subscriptions/types.py +0 -0
  42. {threecommon-0.3.0 → threecommon-0.4.0}/src/threecommon/version.py +0 -0
@@ -5,6 +5,24 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## 0.4.0
9
+
10
+ ### Added
11
+
12
+ - Contacts resource. The new `client.contacts` surface covers the full
13
+ contact lifecycle: `list`, `count`, `retrieve`, `create`, `update`
14
+ (with optional `merge_with` + `resolution` for absorbing a second
15
+ contact during an email change), `delete`, `bulk_upsert`,
16
+ `list_activity`, and both `list_auto_paginate` +
17
+ `list_activity_auto_paginate` iterators. Both sync and async surfaces.
18
+ - New public types on `threecommon.contacts`: `Contact`,
19
+ `ContactWithOrderDetails`, `ContactActivity`, `ContactProperty`,
20
+ `ContactUpdate`, `CreateBody`, `UpdateBody`, `BulkUpsertBody`,
21
+ `BulkUpsertItem`, `ListParams`, `ActivityListParams`, plus result
22
+ envelopes `ListContactsResponse`, `ListActivityResponse`, `CountResult`,
23
+ `BulkUpsertResult`, `DeleteResult`, and the lifecycle / merge / activity
24
+ literal unions.
25
+
8
26
  ## 0.3.0
9
27
 
10
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threecommon
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Official Python client for the 3Common Public API.
5
5
  Project-URL: Homepage, https://github.com/3-Common/sdk/tree/main/sdk-python
6
6
  Project-URL: Issues, https://github.com/3-Common/sdk/issues
@@ -10,7 +10,7 @@ license = { text = "MIT" }
10
10
  authors = [{ name = "3Common", email = "support@3common.com" }]
11
11
  keywords = ["3common", "sdk", "api-client", "events", "invoices"]
12
12
  requires-python = ">=3.10"
13
- version = "0.3.0"
13
+ version = "0.4.0"
14
14
  dependencies = [
15
15
  "httpx>=0.27,<1.0",
16
16
  "pydantic>=2.7,<3.0",
@@ -18,6 +18,7 @@ from threecommon._core.http_client import (
18
18
  from threecommon._core.retry import RetryPolicy
19
19
  from threecommon._core.telemetry import Telemetry
20
20
  from threecommon.config import RetryDelay, resolve_config
21
+ from threecommon.contacts.service import AsyncContactsService, ContactsService
21
22
  from threecommon.events.service import AsyncEventsService, EventsService
22
23
  from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
23
24
  from threecommon.subscriptions.service import AsyncSubscriptionsService, SubscriptionsService
@@ -55,6 +56,11 @@ class ThreeCommon:
55
56
  subscriptions: SubscriptionsService
56
57
  """Subscriptions resource — list, retrieve, create, update, activate, cancel, bill, renew."""
57
58
 
59
+ contacts: ContactsService
60
+ """Contacts resource — ``list``, ``count``, ``retrieve``, ``create``,
61
+ ``update``, ``delete``, ``bulk_upsert``, ``list_activity``, plus
62
+ auto-paginators."""
63
+
58
64
  _http: HTTPClient
59
65
  _telemetry: Telemetry
60
66
 
@@ -98,6 +104,7 @@ class ThreeCommon:
98
104
  self.events = EventsService(self._http)
99
105
  self.invoices = InvoicesService(self._http)
100
106
  self.subscriptions = SubscriptionsService(self._http)
107
+ self.contacts = ContactsService(self._http)
101
108
 
102
109
  def close(self) -> None:
103
110
  """Close the underlying httpx client (no-op if you supplied your own)."""
@@ -126,6 +133,7 @@ class AsyncThreeCommon:
126
133
  events: AsyncEventsService
127
134
  invoices: AsyncInvoicesService
128
135
  subscriptions: AsyncSubscriptionsService
136
+ contacts: AsyncContactsService
129
137
 
130
138
  _http: AsyncHTTPClient
131
139
  _telemetry: Telemetry
@@ -170,6 +178,7 @@ class AsyncThreeCommon:
170
178
  self.events = AsyncEventsService(self._http)
171
179
  self.invoices = AsyncInvoicesService(self._http)
172
180
  self.subscriptions = AsyncSubscriptionsService(self._http)
181
+ self.contacts = AsyncContactsService(self._http)
173
182
 
174
183
  async def aclose(self) -> None:
175
184
  """Close the underlying async httpx client."""
@@ -0,0 +1,58 @@
1
+ """Contacts resource — sync and async clients plus public types.
2
+
3
+ Most callers reach this module through
4
+ [ThreeCommon.contacts][threecommon.ThreeCommon] /
5
+ [AsyncThreeCommon.contacts][threecommon.AsyncThreeCommon]; importing the
6
+ service classes directly is supported for advanced wiring.
7
+ """
8
+
9
+ from threecommon.contacts.service import AsyncContactsService, ContactsService
10
+ from threecommon.contacts.types import (
11
+ ActivityListParams,
12
+ BulkUpsertBody,
13
+ BulkUpsertItem,
14
+ BulkUpsertResult,
15
+ CompactContactStatus,
16
+ Contact,
17
+ ContactActivity,
18
+ ContactActivityType,
19
+ ContactMergeResolution,
20
+ ContactProperty,
21
+ ContactQuickFilter,
22
+ ContactStatus,
23
+ ContactUpdate,
24
+ ContactWithOrderDetails,
25
+ CountResult,
26
+ CreateBody,
27
+ DeleteResult,
28
+ ListActivityResponse,
29
+ ListContactsResponse,
30
+ ListParams,
31
+ UpdateBody,
32
+ )
33
+
34
+ __all__ = (
35
+ "ActivityListParams",
36
+ "AsyncContactsService",
37
+ "BulkUpsertBody",
38
+ "BulkUpsertItem",
39
+ "BulkUpsertResult",
40
+ "CompactContactStatus",
41
+ "Contact",
42
+ "ContactActivity",
43
+ "ContactActivityType",
44
+ "ContactMergeResolution",
45
+ "ContactProperty",
46
+ "ContactQuickFilter",
47
+ "ContactStatus",
48
+ "ContactUpdate",
49
+ "ContactWithOrderDetails",
50
+ "ContactsService",
51
+ "CountResult",
52
+ "CreateBody",
53
+ "DeleteResult",
54
+ "ListActivityResponse",
55
+ "ListContactsResponse",
56
+ "ListParams",
57
+ "UpdateBody",
58
+ )
@@ -0,0 +1,331 @@
1
+ """Sync and async contacts services.
2
+
3
+ Both services share the same wire shape and validation logic; the only
4
+ difference is which HTTP client they call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+ from urllib.parse import quote
11
+
12
+ from threecommon._core.http_client import Request
13
+ from threecommon.contacts.types import (
14
+ ActivityListParams,
15
+ BulkUpsertBody,
16
+ BulkUpsertResult,
17
+ Contact,
18
+ ContactActivity,
19
+ ContactWithOrderDetails,
20
+ CountResult,
21
+ CreateBody,
22
+ DeleteResult,
23
+ ListActivityResponse,
24
+ ListContactsResponse,
25
+ ListParams,
26
+ UpdateBody,
27
+ )
28
+ from threecommon.errors.classes import ValidationError
29
+ from threecommon.pagination import AsyncIter, Iter
30
+
31
+ if TYPE_CHECKING:
32
+ from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
33
+
34
+
35
+ def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
36
+ if params is None:
37
+ return None
38
+ raw = params.model_dump(by_alias=True, exclude_none=True)
39
+ if not raw:
40
+ return None
41
+ return {k: str(v) for k, v in raw.items()}
42
+
43
+
44
+ def _encode_activity_params(params: ActivityListParams | None) -> dict[str, str] | None:
45
+ if params is None:
46
+ return None
47
+ raw = params.model_dump(by_alias=True, exclude_none=True)
48
+ if not raw:
49
+ return None
50
+ return {k: str(v) for k, v in raw.items()}
51
+
52
+
53
+ def _require_id(method: str, contact_id: str) -> None:
54
+ if not contact_id:
55
+ msg = f"contacts.{method}: id must be a non-empty string"
56
+ raise ValidationError(code="missing_id", message=msg)
57
+
58
+
59
+ def _path_for(contact_id: str) -> str:
60
+ return f"/contacts/{quote(contact_id, safe='')}"
61
+
62
+
63
+ def _activity_path(contact_id: str) -> str:
64
+ return f"{_path_for(contact_id)}/activity"
65
+
66
+
67
+ # ---------------
68
+ # Sync
69
+ # ---------------
70
+
71
+
72
+ class ContactsService:
73
+ """Sync contacts service — bound as ``client.contacts`` on [ThreeCommon]."""
74
+
75
+ __slots__ = ("_http",)
76
+
77
+ def __init__(self, http: HTTPClient) -> None:
78
+ self._http = http
79
+
80
+ def list(self, params: ListParams | None = None) -> ListContactsResponse:
81
+ """List the host's contacts (one page).
82
+
83
+ For full iteration use [list_auto_paginate][ContactsService.list_auto_paginate].
84
+ """
85
+ body = self._http.request(
86
+ Request(method="GET", path="/contacts", query=_encode_list_params(params))
87
+ )
88
+ return ListContactsResponse.model_validate(body)
89
+
90
+ def count(self) -> CountResult:
91
+ """Return the total contact count for the host."""
92
+ body = self._http.request(Request(method="GET", path="/contacts/count"))
93
+ return CountResult.model_validate(body["data"])
94
+
95
+ def retrieve(self, contact_id: str) -> Contact:
96
+ """Retrieve a single contact by id."""
97
+ _require_id("retrieve", contact_id)
98
+ body = self._http.request(Request(method="GET", path=_path_for(contact_id)))
99
+ return Contact.model_validate(body["data"])
100
+
101
+ def create(self, body: CreateBody) -> Contact:
102
+ """Create a new contact. Raises ``ConflictError`` on duplicate email."""
103
+ if body is None:
104
+ raise ValidationError(
105
+ code="missing_body", message="contacts.create: body must be non-None"
106
+ )
107
+ payload = body.model_dump(by_alias=True, exclude_none=True)
108
+ response = self._http.request(Request(method="POST", path="/contacts", body=payload))
109
+ return Contact.model_validate(response["data"])
110
+
111
+ def update(self, contact_id: str, body: UpdateBody) -> ContactWithOrderDetails:
112
+ """Update a contact. Returns the richer order-details projection."""
113
+ _require_id("update", contact_id)
114
+ if body is None:
115
+ raise ValidationError(
116
+ code="missing_body", message="contacts.update: body must be non-None"
117
+ )
118
+ payload = body.model_dump(by_alias=True, exclude_none=True)
119
+ response = self._http.request(
120
+ Request(method="PATCH", path=_path_for(contact_id), body=payload)
121
+ )
122
+ return ContactWithOrderDetails.model_validate(response["data"])
123
+
124
+ def delete(self, contact_id: str) -> DeleteResult:
125
+ """Permanently remove a contact. Echoes the removed contact's id."""
126
+ _require_id("delete", contact_id)
127
+ response = self._http.request(Request(method="DELETE", path=_path_for(contact_id)))
128
+ return DeleteResult.model_validate(response["data"])
129
+
130
+ def bulk_upsert(self, body: BulkUpsertBody) -> BulkUpsertResult:
131
+ """Bulk-upsert up to 10,000 contacts in one round-trip.
132
+
133
+ Deduplicated server-side by email; existing rows are updated rather
134
+ than rejected.
135
+ """
136
+ if body is None:
137
+ raise ValidationError(
138
+ code="missing_body", message="contacts.bulk_upsert: body must be non-None"
139
+ )
140
+ payload = body.model_dump(by_alias=True, exclude_none=True)
141
+ response = self._http.request(Request(method="POST", path="/contacts/bulk", body=payload))
142
+ return BulkUpsertResult.model_validate(response["data"])
143
+
144
+ def list_activity(
145
+ self, contact_id: str, params: ActivityListParams | None = None
146
+ ) -> ListActivityResponse:
147
+ """Paginated activity log for a contact."""
148
+ _require_id("list_activity", contact_id)
149
+ body = self._http.request(
150
+ Request(
151
+ method="GET",
152
+ path=_activity_path(contact_id),
153
+ query=_encode_activity_params(params),
154
+ )
155
+ )
156
+ return ListActivityResponse.model_validate(body)
157
+
158
+ def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Contact]:
159
+ """Iterate every contact matching ``params``, paging automatically."""
160
+ start_page = (
161
+ params.page_number if params is not None and params.page_number is not None else 0
162
+ )
163
+
164
+ def fetch(page: int) -> tuple[list[Contact], bool]:
165
+ page_params = (
166
+ params.model_copy(update={"page_number": page})
167
+ if params is not None
168
+ else ListParams(page_number=page)
169
+ )
170
+ body = self._http.request(
171
+ Request(method="GET", path="/contacts", query=_encode_list_params(page_params))
172
+ )
173
+ response = ListContactsResponse.model_validate(body)
174
+ return response.data, response.has_more
175
+
176
+ return Iter(fetch_page=fetch, start_page=start_page)
177
+
178
+ def list_activity_auto_paginate(
179
+ self, contact_id: str, params: ActivityListParams | None = None
180
+ ) -> Iter[ContactActivity]:
181
+ """Iterate every activity record for a contact, paging automatically."""
182
+ _require_id("list_activity_auto_paginate", contact_id)
183
+ start_page = (
184
+ params.page_number if params is not None and params.page_number is not None else 0
185
+ )
186
+
187
+ def fetch(page: int) -> tuple[list[ContactActivity], bool]:
188
+ page_params = (
189
+ params.model_copy(update={"page_number": page})
190
+ if params is not None
191
+ else ActivityListParams(page_number=page)
192
+ )
193
+ body = self._http.request(
194
+ Request(
195
+ method="GET",
196
+ path=_activity_path(contact_id),
197
+ query=_encode_activity_params(page_params),
198
+ )
199
+ )
200
+ response = ListActivityResponse.model_validate(body)
201
+ return response.data, response.has_more
202
+
203
+ return Iter(fetch_page=fetch, start_page=start_page)
204
+
205
+
206
+ # ---------------
207
+ # Async
208
+ # ---------------
209
+
210
+
211
+ class AsyncContactsService:
212
+ """Async contacts service — bound as ``client.contacts`` on [AsyncThreeCommon]."""
213
+
214
+ __slots__ = ("_http",)
215
+
216
+ def __init__(self, http: AsyncHTTPClient) -> None:
217
+ self._http = http
218
+
219
+ async def list(self, params: ListParams | None = None) -> ListContactsResponse:
220
+ body = await self._http.request(
221
+ Request(method="GET", path="/contacts", query=_encode_list_params(params))
222
+ )
223
+ return ListContactsResponse.model_validate(body)
224
+
225
+ async def count(self) -> CountResult:
226
+ body = await self._http.request(Request(method="GET", path="/contacts/count"))
227
+ return CountResult.model_validate(body["data"])
228
+
229
+ async def retrieve(self, contact_id: str) -> Contact:
230
+ _require_id("retrieve", contact_id)
231
+ body = await self._http.request(Request(method="GET", path=_path_for(contact_id)))
232
+ return Contact.model_validate(body["data"])
233
+
234
+ async def create(self, body: CreateBody) -> Contact:
235
+ if body is None:
236
+ raise ValidationError(
237
+ code="missing_body", message="contacts.create: body must be non-None"
238
+ )
239
+ payload = body.model_dump(by_alias=True, exclude_none=True)
240
+ response = await self._http.request(Request(method="POST", path="/contacts", body=payload))
241
+ return Contact.model_validate(response["data"])
242
+
243
+ async def update(self, contact_id: str, body: UpdateBody) -> ContactWithOrderDetails:
244
+ _require_id("update", contact_id)
245
+ if body is None:
246
+ raise ValidationError(
247
+ code="missing_body", message="contacts.update: body must be non-None"
248
+ )
249
+ payload = body.model_dump(by_alias=True, exclude_none=True)
250
+ response = await self._http.request(
251
+ Request(method="PATCH", path=_path_for(contact_id), body=payload)
252
+ )
253
+ return ContactWithOrderDetails.model_validate(response["data"])
254
+
255
+ async def delete(self, contact_id: str) -> DeleteResult:
256
+ _require_id("delete", contact_id)
257
+ response = await self._http.request(Request(method="DELETE", path=_path_for(contact_id)))
258
+ return DeleteResult.model_validate(response["data"])
259
+
260
+ async def bulk_upsert(self, body: BulkUpsertBody) -> BulkUpsertResult:
261
+ if body is None:
262
+ raise ValidationError(
263
+ code="missing_body", message="contacts.bulk_upsert: body must be non-None"
264
+ )
265
+ payload = body.model_dump(by_alias=True, exclude_none=True)
266
+ response = await self._http.request(
267
+ Request(method="POST", path="/contacts/bulk", body=payload)
268
+ )
269
+ return BulkUpsertResult.model_validate(response["data"])
270
+
271
+ async def list_activity(
272
+ self, contact_id: str, params: ActivityListParams | None = None
273
+ ) -> ListActivityResponse:
274
+ _require_id("list_activity", contact_id)
275
+ body = await self._http.request(
276
+ Request(
277
+ method="GET",
278
+ path=_activity_path(contact_id),
279
+ query=_encode_activity_params(params),
280
+ )
281
+ )
282
+ return ListActivityResponse.model_validate(body)
283
+
284
+ def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Contact]:
285
+ """Async iterate every contact matching ``params``."""
286
+ start_page = (
287
+ params.page_number if params is not None and params.page_number is not None else 0
288
+ )
289
+ http = self._http
290
+
291
+ async def fetch(page: int) -> tuple[list[Contact], bool]:
292
+ page_params = (
293
+ params.model_copy(update={"page_number": page})
294
+ if params is not None
295
+ else ListParams(page_number=page)
296
+ )
297
+ body = await http.request(
298
+ Request(method="GET", path="/contacts", query=_encode_list_params(page_params))
299
+ )
300
+ response = ListContactsResponse.model_validate(body)
301
+ return response.data, response.has_more
302
+
303
+ return AsyncIter(fetch_page=fetch, start_page=start_page)
304
+
305
+ def list_activity_auto_paginate(
306
+ self, contact_id: str, params: ActivityListParams | None = None
307
+ ) -> AsyncIter[ContactActivity]:
308
+ """Async iterate every activity record for a contact."""
309
+ _require_id("list_activity_auto_paginate", contact_id)
310
+ start_page = (
311
+ params.page_number if params is not None and params.page_number is not None else 0
312
+ )
313
+ http = self._http
314
+
315
+ async def fetch(page: int) -> tuple[list[ContactActivity], bool]:
316
+ page_params = (
317
+ params.model_copy(update={"page_number": page})
318
+ if params is not None
319
+ else ActivityListParams(page_number=page)
320
+ )
321
+ body = await http.request(
322
+ Request(
323
+ method="GET",
324
+ path=_activity_path(contact_id),
325
+ query=_encode_activity_params(page_params),
326
+ )
327
+ )
328
+ response = ListActivityResponse.model_validate(body)
329
+ return response.data, response.has_more
330
+
331
+ return AsyncIter(fetch_page=fetch, start_page=start_page)
@@ -0,0 +1,310 @@
1
+ """Public types for the contacts resource.
2
+
3
+ Hand-curated Pydantic models that mirror the wire shape (camelCase aliases
4
+ preserved). All response models use ``extra="ignore"`` so newer server-side
5
+ fields don't break older SDK versions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ #: Lifecycle status of a contact.
15
+ #:
16
+ #: * ``opted-in`` / ``unsubscribed``: explicit consent state
17
+ #: * ``unknown``: never recorded a choice
18
+ #: * ``imported``: created via CSV / bulk-upsert before consent was captured
19
+ #: * ``deleted``: soft-deleted
20
+ ContactStatus = Literal["deleted", "imported", "unsubscribed", "opted-in", "unknown"]
21
+
22
+ #: Subset of statuses surfaced on the compact ``Contact`` projection
23
+ #: returned by ``list``, ``retrieve``, and ``create``.
24
+ CompactContactStatus = Literal["unsubscribed", "opted-in", "unknown"]
25
+
26
+ #: How to resolve field-level conflicts when merging a second contact into
27
+ #: the target during ``update``.
28
+ ContactMergeResolution = Literal["safe-merge", "overwrite-merge"]
29
+
30
+ #: The kind of event recorded against a contact in their activity feed.
31
+ ContactActivityType = Literal[
32
+ "checkout_session_completed",
33
+ "product_set_checkout_session_completed",
34
+ "order_refunded",
35
+ "ticket_scanned",
36
+ "email_sent",
37
+ "invoice_paid",
38
+ ]
39
+
40
+ #: Quick status filter accepted by ``ListParams.filter``. Case-insensitive
41
+ #: on the wire; the SDK preserves casing.
42
+ ContactQuickFilter = Literal["all", "opted-in", "unknown", "unsubscribed", "imported"]
43
+
44
+
45
+ class _BaseModel(BaseModel):
46
+ """Shared config: accept snake_case or camelCase, ignore unknown fields."""
47
+
48
+ model_config = ConfigDict(
49
+ populate_by_name=True,
50
+ extra="ignore",
51
+ str_strip_whitespace=False,
52
+ )
53
+
54
+
55
+ class Contact(_BaseModel):
56
+ """A contact in the compact projection returned by ``list``, ``retrieve``,
57
+ and ``create``.
58
+
59
+ Custom-property keys (24-char hex ids) may appear as additional top-level
60
+ fields beyond those declared here — ``extra="ignore"`` on the base config
61
+ means we silently drop them; access via ``model_extra`` if needed.
62
+ """
63
+
64
+ id: str
65
+ first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
66
+ last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
67
+ full_name: str = Field(serialization_alias="fullName", validation_alias="fullName")
68
+ email: str
69
+ phone: str | None = None
70
+ vendor_id: str = Field(serialization_alias="vendorId", validation_alias="vendorId")
71
+ order_sum: int = Field(serialization_alias="orderSum", validation_alias="orderSum")
72
+ gross_sum: int = Field(serialization_alias="grossSum", validation_alias="grossSum")
73
+ first_order: int | None = Field(
74
+ default=None, serialization_alias="firstOrder", validation_alias="firstOrder"
75
+ )
76
+ last_order: int | None = Field(
77
+ default=None, serialization_alias="lastOrder", validation_alias="lastOrder"
78
+ )
79
+ created_at: str | None = Field(
80
+ default=None, serialization_alias="createdAt", validation_alias="createdAt"
81
+ )
82
+ status: CompactContactStatus
83
+ events_attended_ids: list[str] = Field(
84
+ default_factory=list,
85
+ serialization_alias="eventsAttended_IDS",
86
+ validation_alias="eventsAttended_IDS",
87
+ )
88
+ items_purchased_ids: list[str] = Field(
89
+ default_factory=list,
90
+ serialization_alias="itemsPurchased_IDS",
91
+ validation_alias="itemsPurchased_IDS",
92
+ )
93
+ products_purchased_ids: list[str] = Field(
94
+ default_factory=list,
95
+ serialization_alias="productsPurchased_IDS",
96
+ validation_alias="productsPurchased_IDS",
97
+ )
98
+
99
+
100
+ class ContactProperty(_BaseModel):
101
+ """One custom-property entry on the richer order-details projection."""
102
+
103
+ property_id: str = Field(serialization_alias="property_id", validation_alias="property_id")
104
+ value: str | list[str] | bool
105
+
106
+
107
+ class ContactWithOrderDetails(_BaseModel):
108
+ """The richer "order-details" projection returned by ``update``.
109
+
110
+ Includes raw ``events_attended`` / ``items_purchased`` / ``products_purchased``
111
+ arrays and the ``properties`` array, on top of everything in :class:`Contact`.
112
+ The id field on this projection is ``_id`` (Mongo-style), not ``id``.
113
+ """
114
+
115
+ id_: str = Field(serialization_alias="_id", validation_alias="_id")
116
+ email: str
117
+ vendor_id: str = Field(serialization_alias="vendorId", validation_alias="vendorId")
118
+ first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
119
+ last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
120
+ full_name: str = Field(serialization_alias="fullName", validation_alias="fullName")
121
+ phone: str | None = None
122
+ status: ContactStatus
123
+ gross_sum: int = Field(serialization_alias="grossSum", validation_alias="grossSum")
124
+ order_sum: int = Field(serialization_alias="orderSum", validation_alias="orderSum")
125
+ least_recent_order: str | None = Field(
126
+ default=None,
127
+ serialization_alias="leastRecentOrder",
128
+ validation_alias="leastRecentOrder",
129
+ )
130
+ most_recent_order: str | None = Field(
131
+ default=None, serialization_alias="mostRecentOrder", validation_alias="mostRecentOrder"
132
+ )
133
+ events_attended: list[str] = Field(default_factory=list)
134
+ items_purchased: list[str] = Field(default_factory=list)
135
+ products_purchased: list[str] = Field(default_factory=list)
136
+ properties: list[ContactProperty] | None = None
137
+ created_at: str | None = Field(
138
+ default=None, serialization_alias="createdAt", validation_alias="createdAt"
139
+ )
140
+ updated_at: str | None = Field(
141
+ default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
142
+ )
143
+
144
+
145
+ class ContactActivity(_BaseModel):
146
+ """A single activity record in a contact's activity feed."""
147
+
148
+ id_: str = Field(serialization_alias="_id", validation_alias="_id")
149
+ vendor_id: str = Field(serialization_alias="vendor_id", validation_alias="vendor_id")
150
+ email: str
151
+ contact_id: str | None = Field(
152
+ default=None, serialization_alias="contact_id", validation_alias="contact_id"
153
+ )
154
+ type: ContactActivityType
155
+ data: dict[str, Any]
156
+ created_at: str = Field(serialization_alias="createdAt", validation_alias="createdAt")
157
+ updated_at: str = Field(serialization_alias="updatedAt", validation_alias="updatedAt")
158
+
159
+
160
+ class ListContactsResponse(_BaseModel):
161
+ """Successful response shape from ``GET /v1/contacts``."""
162
+
163
+ data: list[Contact]
164
+ has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
165
+ page_number: int = Field(serialization_alias="pageNumber", validation_alias="pageNumber")
166
+ page_size: int = Field(serialization_alias="pageSize", validation_alias="pageSize")
167
+
168
+
169
+ class ListActivityResponse(_BaseModel):
170
+ """Successful response shape from ``GET /v1/contacts/{id}/activity``."""
171
+
172
+ data: list[ContactActivity]
173
+ has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
174
+ page_number: int = Field(serialization_alias="pageNumber", validation_alias="pageNumber")
175
+ page_size: int = Field(serialization_alias="pageSize", validation_alias="pageSize")
176
+
177
+
178
+ class CountResult(_BaseModel):
179
+ """Result shape returned by ``count``."""
180
+
181
+ count: int
182
+
183
+
184
+ class BulkUpsertResult(_BaseModel):
185
+ """Result shape returned by ``bulk_upsert``."""
186
+
187
+ affected: int
188
+
189
+
190
+ class DeleteResult(_BaseModel):
191
+ """Result shape returned by ``delete``. Echoes the removed contact id."""
192
+
193
+ id: str
194
+
195
+
196
+ class ListParams(_BaseModel):
197
+ """Query parameters accepted by ``GET /v1/contacts``."""
198
+
199
+ page_number: int | None = Field(
200
+ default=None, serialization_alias="pageNumber", validation_alias="pageNumber"
201
+ )
202
+ page_size: int | None = Field(
203
+ default=None, serialization_alias="pageSize", validation_alias="pageSize"
204
+ )
205
+ sort_field: str | None = Field(
206
+ default=None, serialization_alias="sortField", validation_alias="sortField"
207
+ )
208
+ sort_direction: Literal["asc", "desc"] | None = Field(
209
+ default=None, serialization_alias="sortDirection", validation_alias="sortDirection"
210
+ )
211
+ filter: ContactQuickFilter | None = None
212
+ filters: str | None = None
213
+ search: str | None = None
214
+
215
+
216
+ class ActivityListParams(_BaseModel):
217
+ """Query parameters accepted by ``GET /v1/contacts/{id}/activity``."""
218
+
219
+ page_number: int | None = Field(
220
+ default=None, serialization_alias="pageNumber", validation_alias="pageNumber"
221
+ )
222
+ page_size: int | None = Field(
223
+ default=None, serialization_alias="pageSize", validation_alias="pageSize"
224
+ )
225
+ filter: ContactActivityType | None = None
226
+ sort: Literal["oldest"] | None = None
227
+
228
+
229
+ class CreateBody(_BaseModel):
230
+ """Body accepted by ``POST /v1/contacts``."""
231
+
232
+ email: str
233
+ first_name: str | None = Field(
234
+ default=None, serialization_alias="firstName", validation_alias="firstName"
235
+ )
236
+ last_name: str | None = Field(
237
+ default=None, serialization_alias="lastName", validation_alias="lastName"
238
+ )
239
+ phone: str | None = None
240
+
241
+
242
+ class ContactUpdate(_BaseModel):
243
+ """The nested ``contact`` object inside :class:`UpdateBody`."""
244
+
245
+ first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
246
+ last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
247
+ email: str
248
+ phone: str | None = None
249
+ status: ContactStatus
250
+
251
+
252
+ class UpdateBody(_BaseModel):
253
+ """Body accepted by ``PATCH /v1/contacts/{id}``.
254
+
255
+ The nested ``contact`` object carries the new field values; ``merge_with``
256
+ and ``resolution`` are set together when an email change collides with
257
+ another contact.
258
+ """
259
+
260
+ contact: ContactUpdate
261
+ merge_with: str | None = Field(
262
+ default=None, serialization_alias="mergeWith", validation_alias="mergeWith"
263
+ )
264
+ resolution: ContactMergeResolution | None = None
265
+
266
+
267
+ class BulkUpsertItem(_BaseModel):
268
+ """One row in :class:`BulkUpsertBody.contacts`. Wider than :class:`CreateBody`
269
+ to support CSV-import flows that carry status + properties + association
270
+ arrays."""
271
+
272
+ model_config = ConfigDict(
273
+ populate_by_name=True,
274
+ # Unlike other request bodies, the bulk endpoint accepts a `catchall`
275
+ # of 24-char hex custom-property keys at the top level. Keep them.
276
+ extra="allow",
277
+ str_strip_whitespace=False,
278
+ )
279
+
280
+ email: str
281
+ first_name: str | None = Field(
282
+ default=None, serialization_alias="firstName", validation_alias="firstName"
283
+ )
284
+ last_name: str | None = Field(
285
+ default=None, serialization_alias="lastName", validation_alias="lastName"
286
+ )
287
+ phone: str | None = None
288
+ status: ContactStatus | None = None
289
+ properties: list[ContactProperty] | None = None
290
+ events_attended_ids: list[str] | None = Field(
291
+ default=None,
292
+ serialization_alias="eventsAttended_IDS",
293
+ validation_alias="eventsAttended_IDS",
294
+ )
295
+ items_purchased_ids: list[str] | None = Field(
296
+ default=None,
297
+ serialization_alias="itemsPurchased_IDS",
298
+ validation_alias="itemsPurchased_IDS",
299
+ )
300
+ products_purchased_ids: list[str] | None = Field(
301
+ default=None,
302
+ serialization_alias="productsPurchased_IDS",
303
+ validation_alias="productsPurchased_IDS",
304
+ )
305
+
306
+
307
+ class BulkUpsertBody(_BaseModel):
308
+ """Body accepted by ``POST /v1/contacts/bulk``."""
309
+
310
+ contacts: list[BulkUpsertItem]
File without changes
File without changes
File without changes