affinity-sdk 0.9.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2445 @@
1
+ """
2
+ V1-only services: Notes, Reminders, Webhooks, Interactions, Fields, and more.
3
+
4
+ These services wrap V1 API endpoints that don't have V2 equivalents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import builtins
11
+ import mimetypes
12
+ from collections.abc import AsyncIterator, Iterator, Sequence
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
16
+
17
+ import httpx
18
+
19
+ from ..downloads import AsyncDownloadedFile, DownloadedFile
20
+ from ..exceptions import AffinityError
21
+ from ..models.entities import (
22
+ FieldCreate,
23
+ FieldMetadata,
24
+ FieldValue,
25
+ FieldValueChange,
26
+ FieldValueCreate,
27
+ )
28
+ from ..models.pagination import V1PaginatedResponse
29
+ from ..models.secondary import (
30
+ EntityFile,
31
+ Interaction,
32
+ InteractionCreate,
33
+ InteractionUpdate,
34
+ Note,
35
+ NoteCreate,
36
+ NoteUpdate,
37
+ RelationshipStrength,
38
+ Reminder,
39
+ ReminderCreate,
40
+ ReminderUpdate,
41
+ WebhookCreate,
42
+ WebhookSubscription,
43
+ WebhookUpdate,
44
+ WhoAmI,
45
+ )
46
+ from ..models.types import (
47
+ AnyFieldId,
48
+ CompanyId,
49
+ EntityType,
50
+ FieldId,
51
+ FieldValueChangeAction,
52
+ FieldValueId,
53
+ FileId,
54
+ InteractionId,
55
+ InteractionType,
56
+ ListEntryId,
57
+ ListId,
58
+ NoteId,
59
+ OpportunityId,
60
+ PersonId,
61
+ ReminderIdType,
62
+ ReminderResetType,
63
+ ReminderStatus,
64
+ ReminderType,
65
+ UserId,
66
+ WebhookId,
67
+ field_id_to_v1_numeric,
68
+ to_v1_value_type_code,
69
+ )
70
+ from ..progress import ProgressCallback
71
+
72
+ if TYPE_CHECKING:
73
+ from ..clients.http import AsyncHTTPClient, HTTPClient
74
+
75
+ # TypeVar for default parameter in get_for_entity()
76
+ T = TypeVar("T")
77
+
78
+ # Sentinel for distinguishing None from "not provided" in get_for_entity()
79
+ _UNSET: Any = object()
80
+
81
+
82
+ def _coerce_isoformat(payload: dict[str, Any], keys: tuple[str, ...]) -> None:
83
+ for key in keys:
84
+ value = payload.get(key)
85
+ if isinstance(value, datetime):
86
+ payload[key] = value.isoformat()
87
+
88
+
89
+ # =============================================================================
90
+ # Notes Service (V1 API)
91
+ # =============================================================================
92
+
93
+
94
+ class NoteService:
95
+ """
96
+ Service for managing notes.
97
+
98
+ V2 provides read-only access; use V1 for create/update/delete.
99
+ """
100
+
101
+ def __init__(self, client: HTTPClient):
102
+ self._client = client
103
+
104
+ def list(
105
+ self,
106
+ *,
107
+ person_id: PersonId | None = None,
108
+ company_id: CompanyId | None = None,
109
+ opportunity_id: OpportunityId | None = None,
110
+ creator_id: UserId | None = None,
111
+ page_size: int | None = None,
112
+ page_token: str | None = None,
113
+ ) -> V1PaginatedResponse[Note]:
114
+ """
115
+ Get notes filtered by entity or creator.
116
+
117
+ Args:
118
+ person_id: Filter notes associated with this person
119
+ company_id: Filter notes associated with this company
120
+ opportunity_id: Filter notes associated with this opportunity
121
+ creator_id: Filter notes created by this user
122
+ page_size: Number of results per page
123
+ page_token: Pagination token from previous response
124
+
125
+ Returns:
126
+ V1PaginatedResponse with notes and next_page_token
127
+ """
128
+ params: dict[str, Any] = {}
129
+ if person_id:
130
+ params["person_id"] = int(person_id)
131
+ if company_id:
132
+ params["organization_id"] = int(company_id)
133
+ if opportunity_id:
134
+ params["opportunity_id"] = int(opportunity_id)
135
+ if creator_id:
136
+ params["creator_id"] = int(creator_id)
137
+ if page_size:
138
+ params["page_size"] = page_size
139
+ if page_token:
140
+ params["page_token"] = page_token
141
+
142
+ data = self._client.get("/notes", params=params or None, v1=True)
143
+ items = data.get("notes", data.get("data", []))
144
+ if not isinstance(items, list):
145
+ items = []
146
+ return V1PaginatedResponse[Note](
147
+ data=[Note.model_validate(n) for n in items],
148
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
149
+ )
150
+
151
+ def get(self, note_id: NoteId) -> Note:
152
+ """Get a single note by ID."""
153
+ data = self._client.get(f"/notes/{note_id}", v1=True)
154
+ return Note.model_validate(data)
155
+
156
+ def create(self, data: NoteCreate) -> Note:
157
+ """
158
+ Create a new note.
159
+
160
+ Must be associated with at least one person, organization,
161
+ opportunity, or parent note (for replies).
162
+ """
163
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
164
+ _coerce_isoformat(payload, ("created_at",))
165
+ if not data.person_ids:
166
+ payload.pop("person_ids", None)
167
+ if not data.company_ids:
168
+ payload.pop("organization_ids", None)
169
+ if not data.opportunity_ids:
170
+ payload.pop("opportunity_ids", None)
171
+
172
+ result = self._client.post("/notes", json=payload, v1=True)
173
+ return Note.model_validate(result)
174
+
175
+ def update(self, note_id: NoteId, data: NoteUpdate) -> Note:
176
+ """Update a note's content."""
177
+ payload = data.model_dump(mode="json", exclude_unset=True, exclude_none=True)
178
+ result = self._client.put(
179
+ f"/notes/{note_id}",
180
+ json=payload,
181
+ v1=True,
182
+ )
183
+ return Note.model_validate(result)
184
+
185
+ def delete(self, note_id: NoteId) -> bool:
186
+ """Delete a note."""
187
+ result = self._client.delete(f"/notes/{note_id}", v1=True)
188
+ return bool(result.get("success", False))
189
+
190
+
191
+ # =============================================================================
192
+ # Reminder Service (V1 API)
193
+ # =============================================================================
194
+
195
+
196
+ class ReminderService:
197
+ """
198
+ Service for managing reminders.
199
+
200
+ Reminders are V1-only in this SDK (create/update/delete via V1).
201
+ """
202
+
203
+ def __init__(self, client: HTTPClient):
204
+ self._client = client
205
+
206
+ def list(
207
+ self,
208
+ *,
209
+ person_id: PersonId | None = None,
210
+ company_id: CompanyId | None = None,
211
+ opportunity_id: OpportunityId | None = None,
212
+ creator_id: UserId | None = None,
213
+ owner_id: UserId | None = None,
214
+ completer_id: UserId | None = None,
215
+ type: ReminderType | None = None,
216
+ reset_type: ReminderResetType | None = None,
217
+ status: ReminderStatus | None = None,
218
+ due_before: datetime | None = None,
219
+ due_after: datetime | None = None,
220
+ page_size: int | None = None,
221
+ page_token: str | None = None,
222
+ ) -> V1PaginatedResponse[Reminder]:
223
+ """
224
+ Get reminders with optional filtering.
225
+
226
+ Args:
227
+ person_id: Filter reminders for this person
228
+ company_id: Filter reminders for this company
229
+ opportunity_id: Filter reminders for this opportunity
230
+ creator_id: Filter by reminder creator
231
+ owner_id: Filter by reminder owner (assignee)
232
+ completer_id: Filter by who completed the reminder
233
+ type: Filter by reminder type (ONE_TIME or RECURRING)
234
+ reset_type: Filter by reset type (FIXED_DATE, DATE_ADDED, or INTERACTION)
235
+ status: Filter by status (ACTIVE, SNOOZED, or COMPLETE)
236
+ due_before: Filter reminders due before this datetime
237
+ due_after: Filter reminders due after this datetime
238
+ page_size: Number of results per page
239
+ page_token: Pagination token from previous response
240
+
241
+ Returns:
242
+ V1PaginatedResponse with reminders and next_page_token
243
+ """
244
+ params: dict[str, Any] = {}
245
+ if person_id:
246
+ params["person_id"] = int(person_id)
247
+ if company_id:
248
+ params["organization_id"] = int(company_id)
249
+ if opportunity_id:
250
+ params["opportunity_id"] = int(opportunity_id)
251
+ if creator_id:
252
+ params["creator_id"] = int(creator_id)
253
+ if owner_id:
254
+ params["owner_id"] = int(owner_id)
255
+ if completer_id:
256
+ params["completer_id"] = int(completer_id)
257
+ if type is not None:
258
+ params["type"] = int(type)
259
+ if reset_type is not None:
260
+ params["reset_type"] = int(reset_type)
261
+ if status is not None:
262
+ params["status"] = int(status)
263
+ if due_before:
264
+ params["due_before"] = due_before.isoformat()
265
+ if due_after:
266
+ params["due_after"] = due_after.isoformat()
267
+ if page_size:
268
+ params["page_size"] = page_size
269
+ if page_token:
270
+ params["page_token"] = page_token
271
+
272
+ data = self._client.get("/reminders", params=params or None, v1=True)
273
+ items = data.get("reminders", data.get("data", []))
274
+ if not isinstance(items, list):
275
+ items = []
276
+ return V1PaginatedResponse[Reminder](
277
+ data=[Reminder.model_validate(r) for r in items],
278
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
279
+ )
280
+
281
+ def get(self, reminder_id: ReminderIdType) -> Reminder:
282
+ """Get a single reminder."""
283
+ data = self._client.get(f"/reminders/{reminder_id}", v1=True)
284
+ return Reminder.model_validate(data)
285
+
286
+ def create(self, data: ReminderCreate) -> Reminder:
287
+ """Create a new reminder."""
288
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
289
+ _coerce_isoformat(payload, ("due_date",))
290
+
291
+ result = self._client.post("/reminders", json=payload, v1=True)
292
+ return Reminder.model_validate(result)
293
+
294
+ def update(self, reminder_id: ReminderIdType, data: ReminderUpdate) -> Reminder:
295
+ """Update a reminder."""
296
+ payload = data.model_dump(
297
+ by_alias=True,
298
+ mode="python",
299
+ exclude_unset=True,
300
+ exclude_none=True,
301
+ )
302
+ _coerce_isoformat(payload, ("due_date",))
303
+
304
+ result = self._client.put(f"/reminders/{reminder_id}", json=payload, v1=True)
305
+ return Reminder.model_validate(result)
306
+
307
+ def delete(self, reminder_id: ReminderIdType) -> bool:
308
+ """Delete a reminder."""
309
+ result = self._client.delete(f"/reminders/{reminder_id}", v1=True)
310
+ return bool(result.get("success", False))
311
+
312
+
313
+ # =============================================================================
314
+ # Webhook Service (V1 API)
315
+ # =============================================================================
316
+
317
+
318
+ class WebhookService:
319
+ """
320
+ Service for managing webhook subscriptions.
321
+
322
+ Note: Limited to 3 subscriptions per Affinity instance.
323
+ """
324
+
325
+ def __init__(self, client: HTTPClient):
326
+ self._client = client
327
+
328
+ def list(self) -> builtins.list[WebhookSubscription]:
329
+ """Get all webhook subscriptions."""
330
+ data = self._client.get("/webhook", v1=True)
331
+ return [WebhookSubscription.model_validate(w) for w in data.get("data", [])]
332
+
333
+ def get(self, webhook_id: WebhookId) -> WebhookSubscription:
334
+ """Get a single webhook subscription."""
335
+ data = self._client.get(f"/webhook/{webhook_id}", v1=True)
336
+ return WebhookSubscription.model_validate(data)
337
+
338
+ def create(self, data: WebhookCreate) -> WebhookSubscription:
339
+ """
340
+ Create a webhook subscription.
341
+
342
+ The webhook URL will receive a validation request.
343
+ """
344
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
345
+ _coerce_isoformat(payload, ("date",))
346
+ if not data.subscriptions:
347
+ payload.pop("subscriptions", None)
348
+
349
+ result = self._client.post("/webhook/subscribe", json=payload, v1=True)
350
+ return WebhookSubscription.model_validate(result)
351
+
352
+ def update(self, webhook_id: WebhookId, data: WebhookUpdate) -> WebhookSubscription:
353
+ """Update a webhook subscription."""
354
+ payload = data.model_dump(
355
+ by_alias=True,
356
+ mode="json",
357
+ exclude_unset=True,
358
+ exclude_none=True,
359
+ )
360
+
361
+ result = self._client.put(f"/webhook/{webhook_id}", json=payload, v1=True)
362
+ return WebhookSubscription.model_validate(result)
363
+
364
+ def delete(self, webhook_id: WebhookId) -> bool:
365
+ """Delete a webhook subscription."""
366
+ result = self._client.delete(f"/webhook/{webhook_id}", v1=True)
367
+ return bool(result.get("success", False))
368
+
369
+
370
+ # =============================================================================
371
+ # Interaction Service (V1 API)
372
+ # =============================================================================
373
+
374
+
375
+ class InteractionService:
376
+ """
377
+ Service for managing interactions (meetings, calls, emails, chats).
378
+
379
+ V2 provides read-only metadata; V1 supports full CRUD.
380
+ """
381
+
382
+ def __init__(self, client: HTTPClient):
383
+ self._client = client
384
+
385
+ def list(
386
+ self,
387
+ *,
388
+ type: InteractionType | None = None,
389
+ start_time: datetime | None = None,
390
+ end_time: datetime | None = None,
391
+ person_id: PersonId | None = None,
392
+ company_id: CompanyId | None = None,
393
+ opportunity_id: OpportunityId | None = None,
394
+ page_size: int | None = None,
395
+ page_token: str | None = None,
396
+ ) -> V1PaginatedResponse[Interaction]:
397
+ """
398
+ Get interactions with optional filtering.
399
+
400
+ The Affinity API requires:
401
+ - type: Interaction type (meeting, call, email, chat)
402
+ - start_time and end_time: Date range (max 1 year)
403
+ - One entity ID: person_id, company_id, or opportunity_id
404
+
405
+ Returns V1 paginated response with `data` and `next_page_token`.
406
+ """
407
+ params: dict[str, Any] = {}
408
+ if type is not None:
409
+ params["type"] = int(type)
410
+ if start_time:
411
+ params["start_time"] = start_time.isoformat()
412
+ if end_time:
413
+ params["end_time"] = end_time.isoformat()
414
+ if person_id:
415
+ params["person_id"] = int(person_id)
416
+ if company_id:
417
+ params["organization_id"] = int(company_id)
418
+ if opportunity_id:
419
+ params["opportunity_id"] = int(opportunity_id)
420
+ if page_size:
421
+ params["page_size"] = page_size
422
+ if page_token:
423
+ params["page_token"] = page_token
424
+
425
+ data = self._client.get("/interactions", params=params or None, v1=True)
426
+ items: Any = None
427
+ if type is not None:
428
+ if int(type) in (int(InteractionType.MEETING), int(InteractionType.CALL)):
429
+ items = data.get("events")
430
+ elif int(type) == int(InteractionType.CHAT_MESSAGE):
431
+ items = data.get("chat_messages")
432
+ elif int(type) == int(InteractionType.EMAIL):
433
+ items = data.get("emails")
434
+
435
+ if items is None:
436
+ items = (
437
+ data.get("interactions")
438
+ or data.get("events")
439
+ or data.get("emails")
440
+ or data.get("chat_messages")
441
+ or data.get("data", [])
442
+ )
443
+ if not isinstance(items, list):
444
+ items = []
445
+ return V1PaginatedResponse[Interaction](
446
+ data=[Interaction.model_validate(i) for i in items],
447
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
448
+ )
449
+
450
+ def get(self, interaction_id: InteractionId, type: InteractionType) -> Interaction:
451
+ """Get a single interaction by ID and type."""
452
+ data = self._client.get(
453
+ f"/interactions/{int(interaction_id)}",
454
+ params={"type": int(type)},
455
+ v1=True,
456
+ )
457
+ return Interaction.model_validate(data)
458
+
459
+ def create(self, data: InteractionCreate) -> Interaction:
460
+ """Create a new interaction (manually logged)."""
461
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
462
+ _coerce_isoformat(payload, ("date",))
463
+
464
+ result = self._client.post("/interactions", json=payload, v1=True)
465
+ return Interaction.model_validate(result)
466
+
467
+ def update(
468
+ self,
469
+ interaction_id: InteractionId,
470
+ type: InteractionType,
471
+ data: InteractionUpdate,
472
+ ) -> Interaction:
473
+ """Update an interaction."""
474
+ payload = data.model_dump(
475
+ by_alias=True,
476
+ mode="python",
477
+ exclude_unset=True,
478
+ exclude_none=True,
479
+ )
480
+ payload["type"] = int(type)
481
+ _coerce_isoformat(payload, ("date",))
482
+
483
+ result = self._client.put(
484
+ f"/interactions/{int(interaction_id)}",
485
+ json=payload,
486
+ v1=True,
487
+ )
488
+ return Interaction.model_validate(result)
489
+
490
+ def delete(self, interaction_id: InteractionId, type: InteractionType) -> bool:
491
+ """Delete an interaction."""
492
+ result = self._client.delete(
493
+ f"/interactions/{int(interaction_id)}",
494
+ params={"type": int(type)},
495
+ v1=True,
496
+ )
497
+ return bool(result.get("success", False))
498
+
499
+
500
+ # =============================================================================
501
+ # Field Service (V1 API)
502
+ # =============================================================================
503
+
504
+
505
+ class FieldService:
506
+ """
507
+ Service for managing custom fields.
508
+
509
+ Use V2 /fields endpoints for reading field metadata.
510
+ Use V1 for creating/deleting fields.
511
+ """
512
+
513
+ def __init__(self, client: HTTPClient):
514
+ self._client = client
515
+
516
+ def list(
517
+ self,
518
+ *,
519
+ list_id: ListId | None = None,
520
+ entity_type: EntityType | None = None,
521
+ ) -> list[FieldMetadata]:
522
+ """
523
+ Get fields (V1 API).
524
+
525
+ For list/person/company field metadata, prefer the V2 read endpoints on the
526
+ corresponding services when available (e.g., `client.lists.get_fields(...)`).
527
+ """
528
+ params: dict[str, Any] = {}
529
+ if list_id:
530
+ params["list_id"] = int(list_id)
531
+ if entity_type is not None:
532
+ params["entity_type"] = int(entity_type)
533
+
534
+ data = self._client.get("/fields", params=params or None, v1=True)
535
+ items = data.get("data", [])
536
+ if not isinstance(items, list):
537
+ items = []
538
+ return [FieldMetadata.model_validate(f) for f in items]
539
+
540
+ def create(self, data: FieldCreate) -> FieldMetadata:
541
+ """Create a custom field."""
542
+ value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
543
+ if value_type_code is None:
544
+ raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
545
+ payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
546
+ payload["entity_type"] = int(data.entity_type)
547
+ payload["value_type"] = value_type_code
548
+ for key in ("allows_multiple", "is_list_specific", "is_required"):
549
+ if not payload.get(key):
550
+ payload.pop(key, None)
551
+
552
+ result = self._client.post("/fields", json=payload, v1=True)
553
+
554
+ # Invalidate field caches
555
+ if self._client.cache:
556
+ self._client.cache.invalidate_prefix("field")
557
+ self._client.cache.invalidate_prefix("list_")
558
+ self._client.cache.invalidate_prefix("person_fields")
559
+ self._client.cache.invalidate_prefix("company_fields")
560
+
561
+ return FieldMetadata.model_validate(result)
562
+
563
+ def delete(self, field_id: FieldId) -> bool:
564
+ """
565
+ Delete a custom field (V1 API).
566
+
567
+ Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
568
+ `field-<digits>` IDs and converts them; enriched/relationship-intelligence
569
+ IDs are not supported.
570
+ """
571
+ numeric_id = field_id_to_v1_numeric(field_id)
572
+ result = self._client.delete(f"/fields/{numeric_id}", v1=True)
573
+
574
+ # Invalidate field caches
575
+ if self._client.cache:
576
+ self._client.cache.invalidate_prefix("field")
577
+ self._client.cache.invalidate_prefix("list_")
578
+ self._client.cache.invalidate_prefix("person_fields")
579
+ self._client.cache.invalidate_prefix("company_fields")
580
+
581
+ return bool(result.get("success", False))
582
+
583
+ def exists(self, field_id: AnyFieldId) -> bool:
584
+ """
585
+ Check if a field exists.
586
+
587
+ Useful for validation before setting field values.
588
+
589
+ Note: This fetches all fields and checks locally. If your code calls
590
+ exists() frequently in a loop, consider caching the result of fields.list()
591
+ yourself.
592
+
593
+ Args:
594
+ field_id: The field ID to check
595
+
596
+ Returns:
597
+ True if the field exists, False otherwise
598
+
599
+ Example:
600
+ if client.fields.exists(FieldId("field-123")):
601
+ client.field_values.create(...)
602
+ """
603
+ target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
604
+ fields = self.list()
605
+ return any(f.id == target_id for f in fields)
606
+
607
+ def get_by_name(self, name: str) -> FieldMetadata | None:
608
+ """
609
+ Find a field by its display name.
610
+
611
+ Uses case-insensitive matching (casefold for i18n support).
612
+
613
+ Note: This fetches all fields and searches locally. If your code calls
614
+ get_by_name() frequently in a loop, consider caching the result of
615
+ fields.list() yourself.
616
+
617
+ Args:
618
+ name: The field display name to search for
619
+
620
+ Returns:
621
+ FieldMetadata if found, None otherwise
622
+
623
+ Example:
624
+ field = client.fields.get_by_name("Primary Email Status")
625
+ if field:
626
+ fv = client.field_values.get_for_entity(field.id, person_id=pid)
627
+ """
628
+ fields = self.list()
629
+ name_folded = name.strip().casefold() # Strip whitespace, then casefold for i18n
630
+ for field in fields:
631
+ if field.name.casefold() == name_folded:
632
+ return field
633
+ return None
634
+
635
+
636
+ # =============================================================================
637
+ # Field Value Service (V1 API)
638
+ # =============================================================================
639
+
640
+
641
+ class FieldValueService:
642
+ """
643
+ Service for managing field values.
644
+
645
+ For list entry field values, prefer ListEntryService.update_field_value().
646
+ Use this for global field values not tied to list entries.
647
+ """
648
+
649
+ def __init__(self, client: HTTPClient):
650
+ self._client = client
651
+
652
+ def list(
653
+ self,
654
+ *,
655
+ person_id: PersonId | None = None,
656
+ company_id: CompanyId | None = None,
657
+ opportunity_id: OpportunityId | None = None,
658
+ list_entry_id: ListEntryId | None = None,
659
+ ) -> list[FieldValue]:
660
+ """
661
+ Get field values for an entity.
662
+
663
+ Exactly one of person_id, company_id, opportunity_id, or list_entry_id
664
+ must be provided.
665
+
666
+ Raises:
667
+ ValueError: If zero or multiple IDs are provided.
668
+ """
669
+ provided = {
670
+ name: value
671
+ for name, value in (
672
+ ("person_id", person_id),
673
+ ("company_id", company_id),
674
+ ("opportunity_id", opportunity_id),
675
+ ("list_entry_id", list_entry_id),
676
+ )
677
+ if value is not None
678
+ }
679
+ if len(provided) == 0:
680
+ raise ValueError(
681
+ "field_values.list() requires exactly one entity ID. "
682
+ "Example: client.field_values.list(person_id=PersonId(123))"
683
+ )
684
+ if len(provided) > 1:
685
+ raise ValueError(
686
+ f"field_values.list() accepts only one entity ID, "
687
+ f"but received {len(provided)}: {', '.join(provided.keys())}. "
688
+ "Call list() separately for each entity."
689
+ )
690
+
691
+ params: dict[str, Any] = {}
692
+ if person_id is not None:
693
+ params["person_id"] = int(person_id)
694
+ if company_id is not None:
695
+ params["organization_id"] = int(company_id)
696
+ if opportunity_id is not None:
697
+ params["opportunity_id"] = int(opportunity_id)
698
+ if list_entry_id is not None:
699
+ params["list_entry_id"] = int(list_entry_id)
700
+
701
+ data = self._client.get("/field-values", params=params or None, v1=True)
702
+ items = data.get("data", [])
703
+ if not isinstance(items, list):
704
+ items = []
705
+ return [FieldValue.model_validate(v) for v in items]
706
+
707
+ def create(self, data: FieldValueCreate) -> FieldValue:
708
+ """
709
+ Create a field value (V1 API).
710
+
711
+ Note: V1 writes require numeric field IDs. The SDK accepts V2-style
712
+ `field-<digits>` IDs and converts them; enriched/relationship-intelligence
713
+ IDs are not supported.
714
+ """
715
+ payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
716
+ payload["field_id"] = field_id_to_v1_numeric(data.field_id)
717
+
718
+ result = self._client.post("/field-values", json=payload, v1=True)
719
+ return FieldValue.model_validate(result)
720
+
721
+ def update(self, field_value_id: FieldValueId, value: Any) -> FieldValue:
722
+ """Update a field value."""
723
+ result = self._client.put(
724
+ f"/field-values/{field_value_id}",
725
+ json={"value": value},
726
+ v1=True,
727
+ )
728
+ return FieldValue.model_validate(result)
729
+
730
+ def delete(self, field_value_id: FieldValueId) -> bool:
731
+ """Delete a field value."""
732
+ result = self._client.delete(f"/field-values/{field_value_id}", v1=True)
733
+ return bool(result.get("success", False))
734
+
735
+ def get_for_entity(
736
+ self,
737
+ field_id: str | FieldId,
738
+ *,
739
+ person_id: PersonId | None = None,
740
+ company_id: CompanyId | None = None,
741
+ opportunity_id: OpportunityId | None = None,
742
+ list_entry_id: ListEntryId | None = None,
743
+ default: T = _UNSET,
744
+ ) -> FieldValue | T | None:
745
+ """
746
+ Get a specific field value for an entity.
747
+
748
+ Convenience method that fetches all field values and returns the one
749
+ matching field_id. Like dict.get(), returns None (or default) if not found.
750
+
751
+ Note: This still makes one API call to fetch all field values for the entity.
752
+ For entities with hundreds of field values, prefer using ``list()`` directly
753
+ if you need to inspect multiple fields.
754
+
755
+ Args:
756
+ field_id: The field to look up (accepts str or FieldId for convenience)
757
+ person_id: Person entity (exactly one entity ID required)
758
+ company_id: Company entity
759
+ opportunity_id: Opportunity entity
760
+ list_entry_id: List entry entity
761
+ default: Value to return if field not found (default: None)
762
+
763
+ Returns:
764
+ FieldValue if the field has a value, default otherwise.
765
+ Note: A FieldValue with ``.value is None`` still counts as "present" (explicit empty).
766
+
767
+ Example:
768
+ # Check if a person has a specific field value
769
+ status = client.field_values.get_for_entity(
770
+ "field-123", # or FieldId("field-123")
771
+ person_id=PersonId(456),
772
+ )
773
+ if status is None:
774
+ print("Field is empty")
775
+ else:
776
+ print(f"Value: {status.value}")
777
+
778
+ # With default value
779
+ status = client.field_values.get_for_entity(
780
+ "field-123",
781
+ person_id=PersonId(456),
782
+ default="N/A",
783
+ )
784
+ """
785
+ all_values = self.list(
786
+ person_id=person_id,
787
+ company_id=company_id,
788
+ opportunity_id=opportunity_id,
789
+ list_entry_id=list_entry_id,
790
+ )
791
+ # Normalize field_id for comparison (handles both str and FieldId)
792
+ target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
793
+ for fv in all_values:
794
+ if fv.field_id == target_id:
795
+ return fv
796
+ return None if default is _UNSET else default
797
+
798
+ def list_batch(
799
+ self,
800
+ person_ids: Sequence[PersonId] | None = None,
801
+ company_ids: Sequence[CompanyId] | None = None,
802
+ opportunity_ids: Sequence[OpportunityId] | None = None,
803
+ *,
804
+ on_error: Literal["raise", "skip"] = "raise",
805
+ ) -> dict[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue]]:
806
+ """
807
+ Get field values for multiple entities.
808
+
809
+ **Performance note:** This makes one API call per entity (O(n) calls).
810
+ There is no server-side batch endpoint. Use this for convenience and
811
+ consistent error handling, not for performance optimization.
812
+ For parallelism, use the async client.
813
+
814
+ Args:
815
+ person_ids: Sequence of person IDs (mutually exclusive with others)
816
+ company_ids: Sequence of company IDs
817
+ opportunity_ids: Sequence of opportunity IDs
818
+ on_error: How to handle errors - "raise" (default) or "skip" failed IDs
819
+
820
+ Returns:
821
+ Dict mapping entity_id -> list of field values.
822
+ Note: Dict ordering is not guaranteed; do not rely on insertion order.
823
+
824
+ Example:
825
+ # Check which persons have a specific field set
826
+ fv_map = client.field_values.list_batch(person_ids=person_ids)
827
+ for person_id, field_values in fv_map.items():
828
+ has_status = any(fv.field_id == target_field for fv in field_values)
829
+ """
830
+ # Validate exactly one sequence provided
831
+ provided = [
832
+ ("person_ids", person_ids),
833
+ ("company_ids", company_ids),
834
+ ("opportunity_ids", opportunity_ids),
835
+ ]
836
+ non_none = [(name, seq) for name, seq in provided if seq is not None]
837
+ if len(non_none) != 1:
838
+ raise ValueError("Exactly one of person_ids, company_ids, or opportunity_ids required")
839
+
840
+ name, ids = non_none[0]
841
+ result: dict[PersonId | CompanyId | OpportunityId, list[FieldValue]] = {}
842
+
843
+ for entity_id in ids:
844
+ try:
845
+ if name == "person_ids":
846
+ result[entity_id] = self.list(person_id=cast(PersonId, entity_id))
847
+ elif name == "company_ids":
848
+ result[entity_id] = self.list(company_id=cast(CompanyId, entity_id))
849
+ else:
850
+ result[entity_id] = self.list(opportunity_id=cast(OpportunityId, entity_id))
851
+ except AffinityError:
852
+ if on_error == "raise":
853
+ raise
854
+ # skip: continue without this entity
855
+ except Exception as e:
856
+ if on_error == "raise":
857
+ # Preserve status_code if available
858
+ status_code = getattr(e, "status_code", None)
859
+ raise AffinityError(
860
+ f"Failed to get field values for {name[:-1]} {entity_id}: {e}",
861
+ status_code=status_code,
862
+ ) from e
863
+
864
+ return result
865
+
866
+
867
+ # =============================================================================
868
+ # Field Value Changes Service (V1 API)
869
+ # =============================================================================
870
+
871
+
872
+ class FieldValueChangesService:
873
+ """Service for querying field value change history (V1 API)."""
874
+
875
+ def __init__(self, client: HTTPClient):
876
+ self._client = client
877
+
878
+ @staticmethod
879
+ def _validate_selector(
880
+ *,
881
+ person_id: PersonId | None,
882
+ company_id: CompanyId | None,
883
+ opportunity_id: OpportunityId | None,
884
+ list_entry_id: ListEntryId | None,
885
+ ) -> None:
886
+ provided = [
887
+ name
888
+ for name, value in (
889
+ ("person_id", person_id),
890
+ ("company_id", company_id),
891
+ ("opportunity_id", opportunity_id),
892
+ ("list_entry_id", list_entry_id),
893
+ )
894
+ if value is not None
895
+ ]
896
+ if len(provided) != 1:
897
+ joined = ", ".join(provided) if provided else "(none)"
898
+ raise ValueError(
899
+ "FieldValueChangesService.list() requires exactly one of: "
900
+ "person_id, company_id, opportunity_id, or list_entry_id; "
901
+ f"got {len(provided)}: {joined}"
902
+ )
903
+
904
+ def list(
905
+ self,
906
+ field_id: AnyFieldId,
907
+ *,
908
+ person_id: PersonId | None = None,
909
+ company_id: CompanyId | None = None,
910
+ opportunity_id: OpportunityId | None = None,
911
+ list_entry_id: ListEntryId | None = None,
912
+ action_type: FieldValueChangeAction | None = None,
913
+ ) -> list[FieldValueChange]:
914
+ """
915
+ Get field value changes for a specific field and entity.
916
+
917
+ This endpoint is not paginated. For large histories, use narrow filters.
918
+ V1 requires numeric field IDs; only `field-<digits>` values are convertible.
919
+ """
920
+ self._validate_selector(
921
+ person_id=person_id,
922
+ company_id=company_id,
923
+ opportunity_id=opportunity_id,
924
+ list_entry_id=list_entry_id,
925
+ )
926
+
927
+ params: dict[str, Any] = {
928
+ "field_id": field_id_to_v1_numeric(field_id),
929
+ }
930
+ if person_id is not None:
931
+ params["person_id"] = int(person_id)
932
+ if company_id is not None:
933
+ params["organization_id"] = int(company_id)
934
+ if opportunity_id is not None:
935
+ params["opportunity_id"] = int(opportunity_id)
936
+ if list_entry_id is not None:
937
+ params["list_entry_id"] = int(list_entry_id)
938
+ if action_type is not None:
939
+ params["action_type"] = int(action_type)
940
+
941
+ data = self._client.get("/field-value-changes", params=params, v1=True)
942
+ items = data.get("data", [])
943
+ if not isinstance(items, list):
944
+ items = []
945
+ return [FieldValueChange.model_validate(item) for item in items]
946
+
947
+ def iter(
948
+ self,
949
+ field_id: AnyFieldId,
950
+ *,
951
+ person_id: PersonId | None = None,
952
+ company_id: CompanyId | None = None,
953
+ opportunity_id: OpportunityId | None = None,
954
+ list_entry_id: ListEntryId | None = None,
955
+ action_type: FieldValueChangeAction | None = None,
956
+ ) -> Iterator[FieldValueChange]:
957
+ """Iterate field value changes (convenience wrapper for list())."""
958
+ yield from self.list(
959
+ field_id,
960
+ person_id=person_id,
961
+ company_id=company_id,
962
+ opportunity_id=opportunity_id,
963
+ list_entry_id=list_entry_id,
964
+ action_type=action_type,
965
+ )
966
+
967
+
968
+ # =============================================================================
969
+ # Relationship Strength Service (V1 API)
970
+ # =============================================================================
971
+
972
+
973
+ class RelationshipStrengthService:
974
+ """Service for querying relationship strengths."""
975
+
976
+ def __init__(self, client: HTTPClient):
977
+ self._client = client
978
+
979
+ def get(
980
+ self,
981
+ external_id: PersonId,
982
+ internal_id: UserId | None = None,
983
+ ) -> list[RelationshipStrength]:
984
+ """
985
+ Get relationship strength(s) for an external person.
986
+
987
+ Args:
988
+ external_id: External person to query
989
+ internal_id: Optional internal person for specific relationship
990
+
991
+ Returns:
992
+ List of relationship strengths (may be empty)
993
+ """
994
+ params: dict[str, Any] = {"external_id": int(external_id)}
995
+ if internal_id:
996
+ params["internal_id"] = int(internal_id)
997
+
998
+ data = self._client.get("/relationships-strengths", params=params, v1=True)
999
+ items = data.get("data", [])
1000
+ if not isinstance(items, list):
1001
+ items = []
1002
+ return [RelationshipStrength.model_validate(r) for r in items]
1003
+
1004
+
1005
+ # =============================================================================
1006
+ # Entity File Service (V1 API)
1007
+ # =============================================================================
1008
+
1009
+
1010
+ class EntityFileService:
1011
+ """Service for managing files attached to entities."""
1012
+
1013
+ def __init__(self, client: HTTPClient):
1014
+ self._client = client
1015
+
1016
+ def _validate_exactly_one_target(
1017
+ self,
1018
+ *,
1019
+ person_id: PersonId | None,
1020
+ company_id: CompanyId | None,
1021
+ opportunity_id: OpportunityId | None,
1022
+ ) -> None:
1023
+ targets = [person_id, company_id, opportunity_id]
1024
+ count = sum(1 for t in targets if t is not None)
1025
+ if count == 1:
1026
+ return
1027
+ if count == 0:
1028
+ raise ValueError("Exactly one of person_id, company_id, or opportunity_id is required")
1029
+ raise ValueError("Only one of person_id, company_id, or opportunity_id may be provided")
1030
+
1031
+ def list(
1032
+ self,
1033
+ *,
1034
+ person_id: PersonId | None = None,
1035
+ company_id: CompanyId | None = None,
1036
+ opportunity_id: OpportunityId | None = None,
1037
+ page_size: int | None = None,
1038
+ page_token: str | None = None,
1039
+ ) -> V1PaginatedResponse[EntityFile]:
1040
+ """Get files attached to an entity."""
1041
+ self._validate_exactly_one_target(
1042
+ person_id=person_id,
1043
+ company_id=company_id,
1044
+ opportunity_id=opportunity_id,
1045
+ )
1046
+ params: dict[str, Any] = {}
1047
+ if person_id:
1048
+ params["person_id"] = int(person_id)
1049
+ if company_id:
1050
+ params["organization_id"] = int(company_id)
1051
+ if opportunity_id:
1052
+ params["opportunity_id"] = int(opportunity_id)
1053
+ if page_size:
1054
+ params["page_size"] = page_size
1055
+ if page_token:
1056
+ params["page_token"] = page_token
1057
+
1058
+ data = self._client.get("/entity-files", params=params or None, v1=True)
1059
+ items = (
1060
+ data.get("entity_files")
1061
+ or data.get("entityFiles")
1062
+ or data.get("files")
1063
+ or data.get("data", [])
1064
+ )
1065
+ if not isinstance(items, list):
1066
+ items = []
1067
+ return V1PaginatedResponse[EntityFile](
1068
+ data=[EntityFile.model_validate(f) for f in items],
1069
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
1070
+ )
1071
+
1072
+ def get(self, file_id: FileId) -> EntityFile:
1073
+ """Get file metadata."""
1074
+ data = self._client.get(f"/entity-files/{file_id}", v1=True)
1075
+ return EntityFile.model_validate(data)
1076
+
1077
+ def download(
1078
+ self,
1079
+ file_id: FileId,
1080
+ *,
1081
+ timeout: httpx.Timeout | float | None = None,
1082
+ deadline_seconds: float | None = None,
1083
+ ) -> bytes:
1084
+ """Download file content."""
1085
+ return self._client.download_file(
1086
+ f"/entity-files/download/{file_id}",
1087
+ v1=True,
1088
+ timeout=timeout,
1089
+ deadline_seconds=deadline_seconds,
1090
+ )
1091
+
1092
+ def download_stream(
1093
+ self,
1094
+ file_id: FileId,
1095
+ *,
1096
+ chunk_size: int = 65_536,
1097
+ on_progress: ProgressCallback | None = None,
1098
+ timeout: httpx.Timeout | float | None = None,
1099
+ deadline_seconds: float | None = None,
1100
+ ) -> Iterator[bytes]:
1101
+ """Stream-download file content in chunks."""
1102
+ return self._client.stream_download(
1103
+ f"/entity-files/download/{file_id}",
1104
+ v1=True,
1105
+ chunk_size=chunk_size,
1106
+ on_progress=on_progress,
1107
+ timeout=timeout,
1108
+ deadline_seconds=deadline_seconds,
1109
+ )
1110
+
1111
+ def download_stream_with_info(
1112
+ self,
1113
+ file_id: FileId,
1114
+ *,
1115
+ chunk_size: int = 65_536,
1116
+ on_progress: ProgressCallback | None = None,
1117
+ timeout: httpx.Timeout | float | None = None,
1118
+ deadline_seconds: float | None = None,
1119
+ ) -> DownloadedFile:
1120
+ """
1121
+ Stream-download a file and return response metadata (headers/filename/size).
1122
+
1123
+ Notes:
1124
+ - `filename` is derived from `Content-Disposition` when present.
1125
+ - If the server does not provide a filename, callers can fall back to
1126
+ `files.get(file_id).name`.
1127
+ """
1128
+ return self._client.stream_download_with_info(
1129
+ f"/entity-files/download/{file_id}",
1130
+ v1=True,
1131
+ chunk_size=chunk_size,
1132
+ on_progress=on_progress,
1133
+ timeout=timeout,
1134
+ deadline_seconds=deadline_seconds,
1135
+ )
1136
+
1137
+ def download_to(
1138
+ self,
1139
+ file_id: FileId,
1140
+ path: str | Path,
1141
+ *,
1142
+ overwrite: bool = False,
1143
+ chunk_size: int = 65_536,
1144
+ on_progress: ProgressCallback | None = None,
1145
+ timeout: httpx.Timeout | float | None = None,
1146
+ deadline_seconds: float | None = None,
1147
+ ) -> Path:
1148
+ """
1149
+ Download a file to disk.
1150
+
1151
+ Args:
1152
+ file_id: The entity file id
1153
+ path: Destination path
1154
+ overwrite: If False, raises FileExistsError when path exists
1155
+ chunk_size: Bytes per chunk
1156
+
1157
+ Returns:
1158
+ The destination path
1159
+ """
1160
+ target = Path(path)
1161
+ if target.exists() and not overwrite:
1162
+ raise FileExistsError(str(target))
1163
+
1164
+ with target.open("wb") as f:
1165
+ for chunk in self.download_stream(
1166
+ file_id,
1167
+ chunk_size=chunk_size,
1168
+ on_progress=on_progress,
1169
+ timeout=timeout,
1170
+ deadline_seconds=deadline_seconds,
1171
+ ):
1172
+ f.write(chunk)
1173
+
1174
+ return target
1175
+
1176
+ def upload(
1177
+ self,
1178
+ files: dict[str, Any],
1179
+ *,
1180
+ person_id: PersonId | None = None,
1181
+ company_id: CompanyId | None = None,
1182
+ opportunity_id: OpportunityId | None = None,
1183
+ ) -> bool:
1184
+ """
1185
+ Upload files to an entity.
1186
+
1187
+ Args:
1188
+ files: Dict of filename to file-like object
1189
+ person_id: Person to attach to
1190
+ company_id: Company to attach to
1191
+ opportunity_id: Opportunity to attach to
1192
+
1193
+ Returns:
1194
+ List of created file records
1195
+ """
1196
+ self._validate_exactly_one_target(
1197
+ person_id=person_id,
1198
+ company_id=company_id,
1199
+ opportunity_id=opportunity_id,
1200
+ )
1201
+ data: dict[str, Any] = {}
1202
+ if person_id:
1203
+ data["person_id"] = int(person_id)
1204
+ if company_id:
1205
+ data["organization_id"] = int(company_id)
1206
+ if opportunity_id:
1207
+ data["opportunity_id"] = int(opportunity_id)
1208
+
1209
+ result = self._client.upload_file(
1210
+ "/entity-files",
1211
+ files=files,
1212
+ data=data,
1213
+ v1=True,
1214
+ )
1215
+ if "success" in result:
1216
+ return bool(result.get("success"))
1217
+ # If the API returns something else on success (e.g., created object),
1218
+ # treat any 2xx JSON response as success (4xx/5xx raise earlier).
1219
+ return True
1220
+
1221
+ def upload_path(
1222
+ self,
1223
+ path: str | Path,
1224
+ *,
1225
+ person_id: PersonId | None = None,
1226
+ company_id: CompanyId | None = None,
1227
+ opportunity_id: OpportunityId | None = None,
1228
+ filename: str | None = None,
1229
+ content_type: str | None = None,
1230
+ on_progress: ProgressCallback | None = None,
1231
+ ) -> bool:
1232
+ """
1233
+ Upload a file from disk.
1234
+
1235
+ Notes:
1236
+ - Returns only a boolean because the API returns `{"success": true}` for uploads.
1237
+ - Progress reporting is best-effort for uploads (start/end only).
1238
+ """
1239
+ self._validate_exactly_one_target(
1240
+ person_id=person_id,
1241
+ company_id=company_id,
1242
+ opportunity_id=opportunity_id,
1243
+ )
1244
+
1245
+ p = Path(path)
1246
+ upload_filename = filename or p.name
1247
+ guessed, _ = mimetypes.guess_type(upload_filename)
1248
+ final_content_type = content_type or guessed or "application/octet-stream"
1249
+ total = p.stat().st_size
1250
+
1251
+ if on_progress:
1252
+ on_progress(0, total, phase="upload")
1253
+
1254
+ with p.open("rb") as f:
1255
+ ok = self.upload(
1256
+ files={"file": (upload_filename, f, final_content_type)},
1257
+ person_id=person_id,
1258
+ company_id=company_id,
1259
+ opportunity_id=opportunity_id,
1260
+ )
1261
+
1262
+ if on_progress:
1263
+ on_progress(total, total, phase="upload")
1264
+
1265
+ return ok
1266
+
1267
+ def upload_bytes(
1268
+ self,
1269
+ data: bytes,
1270
+ filename: str,
1271
+ *,
1272
+ person_id: PersonId | None = None,
1273
+ company_id: CompanyId | None = None,
1274
+ opportunity_id: OpportunityId | None = None,
1275
+ content_type: str | None = None,
1276
+ on_progress: ProgressCallback | None = None,
1277
+ ) -> bool:
1278
+ """
1279
+ Upload in-memory bytes as a file.
1280
+
1281
+ Notes:
1282
+ - Returns only a boolean because the API returns `{"success": true}` for uploads.
1283
+ - Progress reporting is best-effort for uploads (start/end only).
1284
+ """
1285
+ self._validate_exactly_one_target(
1286
+ person_id=person_id,
1287
+ company_id=company_id,
1288
+ opportunity_id=opportunity_id,
1289
+ )
1290
+
1291
+ guessed, _ = mimetypes.guess_type(filename)
1292
+ final_content_type = content_type or guessed or "application/octet-stream"
1293
+ total = len(data)
1294
+
1295
+ if on_progress:
1296
+ on_progress(0, total, phase="upload")
1297
+
1298
+ ok = self.upload(
1299
+ files={"file": (filename, data, final_content_type)},
1300
+ person_id=person_id,
1301
+ company_id=company_id,
1302
+ opportunity_id=opportunity_id,
1303
+ )
1304
+
1305
+ if on_progress:
1306
+ on_progress(total, total, phase="upload")
1307
+
1308
+ return ok
1309
+
1310
+ def all(
1311
+ self,
1312
+ *,
1313
+ person_id: PersonId | None = None,
1314
+ company_id: CompanyId | None = None,
1315
+ opportunity_id: OpportunityId | None = None,
1316
+ ) -> Iterator[EntityFile]:
1317
+ """Iterate through all files for an entity with automatic pagination."""
1318
+ self._validate_exactly_one_target(
1319
+ person_id=person_id,
1320
+ company_id=company_id,
1321
+ opportunity_id=opportunity_id,
1322
+ )
1323
+
1324
+ page_token: str | None = None
1325
+ while True:
1326
+ page = self.list(
1327
+ person_id=person_id,
1328
+ company_id=company_id,
1329
+ opportunity_id=opportunity_id,
1330
+ page_token=page_token,
1331
+ )
1332
+ yield from page.data
1333
+ if not page.has_next:
1334
+ break
1335
+ page_token = page.next_page_token
1336
+
1337
+ def iter(
1338
+ self,
1339
+ *,
1340
+ person_id: PersonId | None = None,
1341
+ company_id: CompanyId | None = None,
1342
+ opportunity_id: OpportunityId | None = None,
1343
+ ) -> Iterator[EntityFile]:
1344
+ """Auto-paginate all files (alias for `all()`)."""
1345
+ return self.all(
1346
+ person_id=person_id,
1347
+ company_id=company_id,
1348
+ opportunity_id=opportunity_id,
1349
+ )
1350
+
1351
+
1352
+ # =============================================================================
1353
+ # Auth Service
1354
+ # =============================================================================
1355
+
1356
+
1357
+ class AuthService:
1358
+ """Service for authentication info."""
1359
+
1360
+ def __init__(self, client: HTTPClient):
1361
+ self._client = client
1362
+
1363
+ def whoami(self) -> WhoAmI:
1364
+ """Get info about current user and API key."""
1365
+ # V2 also has this endpoint
1366
+ data = self._client.get("/auth/whoami")
1367
+ return WhoAmI.model_validate(data)
1368
+
1369
+ # Note: rate limit handling is exposed via `client.rate_limits` (version-agnostic).
1370
+
1371
+
1372
+ # =============================================================================
1373
+ # Async V1-only services
1374
+ # =============================================================================
1375
+
1376
+
1377
+ class AsyncNoteService:
1378
+ """
1379
+ Async service for managing notes (V1 API).
1380
+
1381
+ V2 provides read-only access; use V1 for create/update/delete.
1382
+ """
1383
+
1384
+ def __init__(self, client: AsyncHTTPClient):
1385
+ self._client = client
1386
+
1387
+ async def list(
1388
+ self,
1389
+ *,
1390
+ person_id: PersonId | None = None,
1391
+ company_id: CompanyId | None = None,
1392
+ opportunity_id: OpportunityId | None = None,
1393
+ creator_id: UserId | None = None,
1394
+ page_size: int | None = None,
1395
+ page_token: str | None = None,
1396
+ ) -> V1PaginatedResponse[Note]:
1397
+ params: dict[str, Any] = {}
1398
+ if person_id:
1399
+ params["person_id"] = int(person_id)
1400
+ if company_id:
1401
+ params["organization_id"] = int(company_id)
1402
+ if opportunity_id:
1403
+ params["opportunity_id"] = int(opportunity_id)
1404
+ if creator_id:
1405
+ params["creator_id"] = int(creator_id)
1406
+ if page_size:
1407
+ params["page_size"] = page_size
1408
+ if page_token:
1409
+ params["page_token"] = page_token
1410
+
1411
+ data = await self._client.get("/notes", params=params or None, v1=True)
1412
+ items = data.get("notes", data.get("data", []))
1413
+ if not isinstance(items, list):
1414
+ items = []
1415
+ return V1PaginatedResponse[Note](
1416
+ data=[Note.model_validate(n) for n in items],
1417
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
1418
+ )
1419
+
1420
+ async def get(self, note_id: NoteId) -> Note:
1421
+ data = await self._client.get(f"/notes/{note_id}", v1=True)
1422
+ return Note.model_validate(data)
1423
+
1424
+ async def create(self, data: NoteCreate) -> Note:
1425
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
1426
+ _coerce_isoformat(payload, ("created_at",))
1427
+ if not data.person_ids:
1428
+ payload.pop("person_ids", None)
1429
+ if not data.company_ids:
1430
+ payload.pop("organization_ids", None)
1431
+ if not data.opportunity_ids:
1432
+ payload.pop("opportunity_ids", None)
1433
+
1434
+ result = await self._client.post("/notes", json=payload, v1=True)
1435
+ return Note.model_validate(result)
1436
+
1437
+ async def update(self, note_id: NoteId, data: NoteUpdate) -> Note:
1438
+ payload = data.model_dump(mode="json", exclude_unset=True, exclude_none=True)
1439
+ result = await self._client.put(
1440
+ f"/notes/{note_id}",
1441
+ json=payload,
1442
+ v1=True,
1443
+ )
1444
+ return Note.model_validate(result)
1445
+
1446
+ async def delete(self, note_id: NoteId) -> bool:
1447
+ result = await self._client.delete(f"/notes/{note_id}", v1=True)
1448
+ return bool(result.get("success", False))
1449
+
1450
+
1451
+ class AsyncReminderService:
1452
+ """Async service for managing reminders (V1 API)."""
1453
+
1454
+ def __init__(self, client: AsyncHTTPClient):
1455
+ self._client = client
1456
+
1457
+ async def list(
1458
+ self,
1459
+ *,
1460
+ person_id: PersonId | None = None,
1461
+ company_id: CompanyId | None = None,
1462
+ opportunity_id: OpportunityId | None = None,
1463
+ creator_id: UserId | None = None,
1464
+ owner_id: UserId | None = None,
1465
+ completer_id: UserId | None = None,
1466
+ type: ReminderType | None = None,
1467
+ reset_type: ReminderResetType | None = None,
1468
+ status: ReminderStatus | None = None,
1469
+ due_before: datetime | None = None,
1470
+ due_after: datetime | None = None,
1471
+ page_size: int | None = None,
1472
+ page_token: str | None = None,
1473
+ ) -> V1PaginatedResponse[Reminder]:
1474
+ params: dict[str, Any] = {}
1475
+ if person_id:
1476
+ params["person_id"] = int(person_id)
1477
+ if company_id:
1478
+ params["organization_id"] = int(company_id)
1479
+ if opportunity_id:
1480
+ params["opportunity_id"] = int(opportunity_id)
1481
+ if creator_id:
1482
+ params["creator_id"] = int(creator_id)
1483
+ if owner_id:
1484
+ params["owner_id"] = int(owner_id)
1485
+ if completer_id:
1486
+ params["completer_id"] = int(completer_id)
1487
+ if type is not None:
1488
+ params["type"] = int(type)
1489
+ if reset_type is not None:
1490
+ params["reset_type"] = int(reset_type)
1491
+ if status is not None:
1492
+ params["status"] = int(status)
1493
+ if due_before:
1494
+ params["due_before"] = due_before.isoformat()
1495
+ if due_after:
1496
+ params["due_after"] = due_after.isoformat()
1497
+ if page_size:
1498
+ params["page_size"] = page_size
1499
+ if page_token:
1500
+ params["page_token"] = page_token
1501
+
1502
+ data = await self._client.get("/reminders", params=params or None, v1=True)
1503
+ items = data.get("reminders", data.get("data", []))
1504
+ if not isinstance(items, list):
1505
+ items = []
1506
+ return V1PaginatedResponse[Reminder](
1507
+ data=[Reminder.model_validate(r) for r in items],
1508
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
1509
+ )
1510
+
1511
+ async def get(self, reminder_id: ReminderIdType) -> Reminder:
1512
+ data = await self._client.get(f"/reminders/{reminder_id}", v1=True)
1513
+ return Reminder.model_validate(data)
1514
+
1515
+ async def create(self, data: ReminderCreate) -> Reminder:
1516
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
1517
+ _coerce_isoformat(payload, ("due_date",))
1518
+
1519
+ result = await self._client.post("/reminders", json=payload, v1=True)
1520
+ return Reminder.model_validate(result)
1521
+
1522
+ async def update(self, reminder_id: ReminderIdType, data: ReminderUpdate) -> Reminder:
1523
+ payload = data.model_dump(
1524
+ by_alias=True,
1525
+ mode="python",
1526
+ exclude_unset=True,
1527
+ exclude_none=True,
1528
+ )
1529
+ _coerce_isoformat(payload, ("due_date",))
1530
+
1531
+ result = await self._client.put(f"/reminders/{reminder_id}", json=payload, v1=True)
1532
+ return Reminder.model_validate(result)
1533
+
1534
+ async def delete(self, reminder_id: ReminderIdType) -> bool:
1535
+ result = await self._client.delete(f"/reminders/{reminder_id}", v1=True)
1536
+ return bool(result.get("success", False))
1537
+
1538
+
1539
+ class AsyncWebhookService:
1540
+ """Async service for managing webhook subscriptions (V1 API)."""
1541
+
1542
+ def __init__(self, client: AsyncHTTPClient):
1543
+ self._client = client
1544
+
1545
+ async def list(self) -> builtins.list[WebhookSubscription]:
1546
+ data = await self._client.get("/webhook", v1=True)
1547
+ items = data.get("data", [])
1548
+ if not isinstance(items, list):
1549
+ items = []
1550
+ return [WebhookSubscription.model_validate(w) for w in items]
1551
+
1552
+ async def get(self, webhook_id: WebhookId) -> WebhookSubscription:
1553
+ data = await self._client.get(f"/webhook/{webhook_id}", v1=True)
1554
+ return WebhookSubscription.model_validate(data)
1555
+
1556
+ async def create(self, data: WebhookCreate) -> WebhookSubscription:
1557
+ payload = data.model_dump(by_alias=True, mode="json", exclude_none=True)
1558
+ if not data.subscriptions:
1559
+ payload.pop("subscriptions", None)
1560
+ result = await self._client.post("/webhook/subscribe", json=payload, v1=True)
1561
+ return WebhookSubscription.model_validate(result)
1562
+
1563
+ async def update(self, webhook_id: WebhookId, data: WebhookUpdate) -> WebhookSubscription:
1564
+ payload = data.model_dump(
1565
+ by_alias=True,
1566
+ mode="json",
1567
+ exclude_unset=True,
1568
+ exclude_none=True,
1569
+ )
1570
+ result = await self._client.put(f"/webhook/{webhook_id}", json=payload, v1=True)
1571
+ return WebhookSubscription.model_validate(result)
1572
+
1573
+ async def delete(self, webhook_id: WebhookId) -> bool:
1574
+ result = await self._client.delete(f"/webhook/{webhook_id}", v1=True)
1575
+ return bool(result.get("success", False))
1576
+
1577
+
1578
+ class AsyncInteractionService:
1579
+ """Async service for managing interactions (V1 API)."""
1580
+
1581
+ def __init__(self, client: AsyncHTTPClient):
1582
+ self._client = client
1583
+
1584
+ async def list(
1585
+ self,
1586
+ *,
1587
+ type: InteractionType | None = None,
1588
+ start_time: datetime | None = None,
1589
+ end_time: datetime | None = None,
1590
+ person_id: PersonId | None = None,
1591
+ company_id: CompanyId | None = None,
1592
+ opportunity_id: OpportunityId | None = None,
1593
+ page_size: int | None = None,
1594
+ page_token: str | None = None,
1595
+ ) -> V1PaginatedResponse[Interaction]:
1596
+ params: dict[str, Any] = {}
1597
+ if type is not None:
1598
+ params["type"] = int(type)
1599
+ if start_time:
1600
+ params["start_time"] = start_time.isoformat()
1601
+ if end_time:
1602
+ params["end_time"] = end_time.isoformat()
1603
+ if person_id:
1604
+ params["person_id"] = int(person_id)
1605
+ if company_id:
1606
+ params["organization_id"] = int(company_id)
1607
+ if opportunity_id:
1608
+ params["opportunity_id"] = int(opportunity_id)
1609
+ if page_size:
1610
+ params["page_size"] = page_size
1611
+ if page_token:
1612
+ params["page_token"] = page_token
1613
+
1614
+ data = await self._client.get("/interactions", params=params or None, v1=True)
1615
+ items: Any = None
1616
+ if type is not None:
1617
+ if int(type) in (int(InteractionType.MEETING), int(InteractionType.CALL)):
1618
+ items = data.get("events")
1619
+ elif int(type) == int(InteractionType.CHAT_MESSAGE):
1620
+ items = data.get("chat_messages")
1621
+ elif int(type) == int(InteractionType.EMAIL):
1622
+ items = data.get("emails")
1623
+
1624
+ if items is None:
1625
+ items = (
1626
+ data.get("interactions")
1627
+ or data.get("events")
1628
+ or data.get("emails")
1629
+ or data.get("chat_messages")
1630
+ or data.get("data", [])
1631
+ )
1632
+ if not isinstance(items, list):
1633
+ items = []
1634
+ return V1PaginatedResponse[Interaction](
1635
+ data=[Interaction.model_validate(i) for i in items],
1636
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
1637
+ )
1638
+
1639
+ async def get(self, interaction_id: InteractionId, type: InteractionType) -> Interaction:
1640
+ data = await self._client.get(
1641
+ f"/interactions/{interaction_id}",
1642
+ params={"type": int(type)},
1643
+ v1=True,
1644
+ )
1645
+ return Interaction.model_validate(data)
1646
+
1647
+ async def create(self, data: InteractionCreate) -> Interaction:
1648
+ payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
1649
+ _coerce_isoformat(payload, ("date",))
1650
+
1651
+ result = await self._client.post("/interactions", json=payload, v1=True)
1652
+ return Interaction.model_validate(result)
1653
+
1654
+ async def update(
1655
+ self,
1656
+ interaction_id: InteractionId,
1657
+ type: InteractionType,
1658
+ data: InteractionUpdate,
1659
+ ) -> Interaction:
1660
+ payload = data.model_dump(
1661
+ by_alias=True,
1662
+ mode="python",
1663
+ exclude_unset=True,
1664
+ exclude_none=True,
1665
+ )
1666
+ payload["type"] = int(type)
1667
+ _coerce_isoformat(payload, ("date",))
1668
+
1669
+ result = await self._client.put(f"/interactions/{interaction_id}", json=payload, v1=True)
1670
+ return Interaction.model_validate(result)
1671
+
1672
+ async def delete(self, interaction_id: InteractionId, type: InteractionType) -> bool:
1673
+ result = await self._client.delete(
1674
+ f"/interactions/{interaction_id}",
1675
+ params={"type": int(type)},
1676
+ v1=True,
1677
+ )
1678
+ return bool(result.get("success", False))
1679
+
1680
+
1681
+ class AsyncFieldService:
1682
+ """Async service for managing custom fields (V1 API)."""
1683
+
1684
+ def __init__(self, client: AsyncHTTPClient):
1685
+ self._client = client
1686
+
1687
+ async def list(
1688
+ self,
1689
+ *,
1690
+ list_id: ListId | None = None,
1691
+ entity_type: EntityType | None = None,
1692
+ ) -> builtins.list[FieldMetadata]:
1693
+ params: dict[str, Any] = {}
1694
+ if list_id:
1695
+ params["list_id"] = int(list_id)
1696
+ if entity_type is not None:
1697
+ params["entity_type"] = int(entity_type)
1698
+
1699
+ data = await self._client.get("/fields", params=params or None, v1=True)
1700
+ items = data.get("data", [])
1701
+ if not isinstance(items, list):
1702
+ items = []
1703
+ return [FieldMetadata.model_validate(f) for f in items]
1704
+
1705
+ async def create(self, data: FieldCreate) -> FieldMetadata:
1706
+ value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
1707
+ if value_type_code is None:
1708
+ raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
1709
+ payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
1710
+ payload["entity_type"] = int(data.entity_type)
1711
+ payload["value_type"] = value_type_code
1712
+ for key in ("allows_multiple", "is_list_specific", "is_required"):
1713
+ if not payload.get(key):
1714
+ payload.pop(key, None)
1715
+
1716
+ result = await self._client.post("/fields", json=payload, v1=True)
1717
+
1718
+ if self._client.cache:
1719
+ self._client.cache.invalidate_prefix("field")
1720
+ self._client.cache.invalidate_prefix("list_")
1721
+ self._client.cache.invalidate_prefix("person_fields")
1722
+ self._client.cache.invalidate_prefix("company_fields")
1723
+
1724
+ return FieldMetadata.model_validate(result)
1725
+
1726
+ async def delete(self, field_id: FieldId) -> bool:
1727
+ """
1728
+ Delete a custom field (V1 API).
1729
+
1730
+ Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
1731
+ `field-<digits>` IDs and converts them; enriched/relationship-intelligence
1732
+ IDs are not supported.
1733
+ """
1734
+ numeric_id = field_id_to_v1_numeric(field_id)
1735
+ result = await self._client.delete(f"/fields/{numeric_id}", v1=True)
1736
+
1737
+ if self._client.cache:
1738
+ self._client.cache.invalidate_prefix("field")
1739
+ self._client.cache.invalidate_prefix("list_")
1740
+ self._client.cache.invalidate_prefix("person_fields")
1741
+ self._client.cache.invalidate_prefix("company_fields")
1742
+
1743
+ return bool(result.get("success", False))
1744
+
1745
+ async def exists(self, field_id: AnyFieldId) -> bool:
1746
+ """
1747
+ Check if a field exists.
1748
+
1749
+ Useful for validation before setting field values.
1750
+
1751
+ Note: This fetches all fields and checks locally. If your code calls
1752
+ exists() frequently in a loop, consider caching the result of fields.list()
1753
+ yourself.
1754
+
1755
+ Args:
1756
+ field_id: The field ID to check
1757
+
1758
+ Returns:
1759
+ True if the field exists, False otherwise
1760
+
1761
+ Example:
1762
+ if await client.fields.exists(FieldId("field-123")):
1763
+ await client.field_values.create(...)
1764
+ """
1765
+ target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
1766
+ fields = await self.list()
1767
+ return any(f.id == target_id for f in fields)
1768
+
1769
+ async def get_by_name(self, name: str) -> FieldMetadata | None:
1770
+ """
1771
+ Find a field by its display name.
1772
+
1773
+ Uses case-insensitive matching (casefold for i18n support).
1774
+
1775
+ Note: This fetches all fields and searches locally. If your code calls
1776
+ get_by_name() frequently in a loop, consider caching the result of
1777
+ fields.list() yourself.
1778
+
1779
+ Args:
1780
+ name: The field display name to search for
1781
+
1782
+ Returns:
1783
+ FieldMetadata if found, None otherwise
1784
+
1785
+ Example:
1786
+ field = await client.fields.get_by_name("Primary Email Status")
1787
+ if field:
1788
+ fv = await client.field_values.get_for_entity(field.id, person_id=pid)
1789
+ """
1790
+ fields = await self.list()
1791
+ name_folded = name.strip().casefold() # Strip whitespace, then casefold for i18n
1792
+ for field in fields:
1793
+ if field.name.casefold() == name_folded:
1794
+ return field
1795
+ return None
1796
+
1797
+
1798
+ class AsyncFieldValueService:
1799
+ """Async service for managing field values (V1 API)."""
1800
+
1801
+ def __init__(self, client: AsyncHTTPClient):
1802
+ self._client = client
1803
+
1804
+ async def list(
1805
+ self,
1806
+ *,
1807
+ person_id: PersonId | None = None,
1808
+ company_id: CompanyId | None = None,
1809
+ opportunity_id: OpportunityId | None = None,
1810
+ list_entry_id: ListEntryId | None = None,
1811
+ ) -> builtins.list[FieldValue]:
1812
+ provided = {
1813
+ name: value
1814
+ for name, value in (
1815
+ ("person_id", person_id),
1816
+ ("company_id", company_id),
1817
+ ("opportunity_id", opportunity_id),
1818
+ ("list_entry_id", list_entry_id),
1819
+ )
1820
+ if value is not None
1821
+ }
1822
+ if len(provided) == 0:
1823
+ raise ValueError(
1824
+ "field_values.list() requires exactly one entity ID. "
1825
+ "Example: client.field_values.list(person_id=PersonId(123))"
1826
+ )
1827
+ if len(provided) > 1:
1828
+ raise ValueError(
1829
+ f"field_values.list() accepts only one entity ID, "
1830
+ f"but received {len(provided)}: {', '.join(provided.keys())}. "
1831
+ "Call list() separately for each entity."
1832
+ )
1833
+
1834
+ params: dict[str, Any] = {}
1835
+ if person_id is not None:
1836
+ params["person_id"] = int(person_id)
1837
+ if company_id is not None:
1838
+ params["organization_id"] = int(company_id)
1839
+ if opportunity_id is not None:
1840
+ params["opportunity_id"] = int(opportunity_id)
1841
+ if list_entry_id is not None:
1842
+ params["list_entry_id"] = int(list_entry_id)
1843
+
1844
+ data = await self._client.get("/field-values", params=params or None, v1=True)
1845
+ items = data.get("data", [])
1846
+ if not isinstance(items, list):
1847
+ items = []
1848
+ return [FieldValue.model_validate(v) for v in items]
1849
+
1850
+ async def create(self, data: FieldValueCreate) -> FieldValue:
1851
+ """
1852
+ Create a field value (V1 API).
1853
+
1854
+ Note: V1 writes require numeric field IDs. The SDK accepts V2-style
1855
+ `field-<digits>` IDs and converts them; enriched/relationship-intelligence
1856
+ IDs are not supported.
1857
+ """
1858
+ payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
1859
+ payload["field_id"] = field_id_to_v1_numeric(data.field_id)
1860
+
1861
+ result = await self._client.post("/field-values", json=payload, v1=True)
1862
+ return FieldValue.model_validate(result)
1863
+
1864
+ async def update(self, field_value_id: FieldValueId, value: Any) -> FieldValue:
1865
+ result = await self._client.put(
1866
+ f"/field-values/{field_value_id}",
1867
+ json={"value": value},
1868
+ v1=True,
1869
+ )
1870
+ return FieldValue.model_validate(result)
1871
+
1872
+ async def delete(self, field_value_id: FieldValueId) -> bool:
1873
+ result = await self._client.delete(f"/field-values/{field_value_id}", v1=True)
1874
+ return bool(result.get("success", False))
1875
+
1876
+ async def get_for_entity(
1877
+ self,
1878
+ field_id: str | FieldId,
1879
+ *,
1880
+ person_id: PersonId | None = None,
1881
+ company_id: CompanyId | None = None,
1882
+ opportunity_id: OpportunityId | None = None,
1883
+ list_entry_id: ListEntryId | None = None,
1884
+ default: T = _UNSET,
1885
+ ) -> FieldValue | T | None:
1886
+ """
1887
+ Get a specific field value for an entity.
1888
+
1889
+ Convenience method that fetches all field values and returns the one
1890
+ matching field_id. Like dict.get(), returns None (or default) if not found.
1891
+
1892
+ Note: This still makes one API call to fetch all field values for the entity.
1893
+ For entities with hundreds of field values, prefer using ``list()`` directly
1894
+ if you need to inspect multiple fields.
1895
+
1896
+ Args:
1897
+ field_id: The field to look up (accepts str or FieldId for convenience)
1898
+ person_id: Person entity (exactly one entity ID required)
1899
+ company_id: Company entity
1900
+ opportunity_id: Opportunity entity
1901
+ list_entry_id: List entry entity
1902
+ default: Value to return if field not found (default: None)
1903
+
1904
+ Returns:
1905
+ FieldValue if the field has a value, default otherwise.
1906
+ Note: A FieldValue with ``.value is None`` still counts as "present" (explicit empty).
1907
+
1908
+ Example:
1909
+ # Check if a person has a specific field value
1910
+ status = await client.field_values.get_for_entity(
1911
+ "field-123", # or FieldId("field-123")
1912
+ person_id=PersonId(456),
1913
+ )
1914
+ if status is None:
1915
+ print("Field is empty")
1916
+ else:
1917
+ print(f"Value: {status.value}")
1918
+
1919
+ # With default value
1920
+ status = await client.field_values.get_for_entity(
1921
+ "field-123",
1922
+ person_id=PersonId(456),
1923
+ default="N/A",
1924
+ )
1925
+ """
1926
+ all_values = await self.list(
1927
+ person_id=person_id,
1928
+ company_id=company_id,
1929
+ opportunity_id=opportunity_id,
1930
+ list_entry_id=list_entry_id,
1931
+ )
1932
+ # Normalize field_id for comparison (handles both str and FieldId)
1933
+ target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
1934
+ for fv in all_values:
1935
+ if fv.field_id == target_id:
1936
+ return fv
1937
+ return None if default is _UNSET else default
1938
+
1939
+ async def list_batch(
1940
+ self,
1941
+ person_ids: Sequence[PersonId] | None = None,
1942
+ company_ids: Sequence[CompanyId] | None = None,
1943
+ opportunity_ids: Sequence[OpportunityId] | None = None,
1944
+ *,
1945
+ on_error: Literal["raise", "skip"] = "raise",
1946
+ concurrency: int | None = 10,
1947
+ ) -> dict[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue]]:
1948
+ """
1949
+ Get field values for multiple entities concurrently.
1950
+
1951
+ Uses asyncio.gather() for concurrent API calls, bounded by semaphore.
1952
+ Significant speedup compared to sequential sync version.
1953
+
1954
+ Args:
1955
+ person_ids: Sequence of person IDs (mutually exclusive with others)
1956
+ company_ids: Sequence of company IDs
1957
+ opportunity_ids: Sequence of opportunity IDs
1958
+ on_error: How to handle errors - "raise" (default) or "skip" failed IDs
1959
+ concurrency: Maximum concurrent requests. Default 10. Set to None for unlimited.
1960
+
1961
+ Returns:
1962
+ Dict mapping entity_id -> list of field values.
1963
+ Note: Dict ordering is not guaranteed; do not rely on insertion order.
1964
+
1965
+ Example:
1966
+ # Check which persons have a specific field set
1967
+ fv_map = await client.field_values.list_batch(person_ids=person_ids)
1968
+ for person_id, field_values in fv_map.items():
1969
+ has_status = any(fv.field_id == target_field for fv in field_values)
1970
+ """
1971
+ # Validate exactly one sequence provided
1972
+ provided = [
1973
+ ("person_ids", person_ids),
1974
+ ("company_ids", company_ids),
1975
+ ("opportunity_ids", opportunity_ids),
1976
+ ]
1977
+ non_none = [(name, seq) for name, seq in provided if seq is not None]
1978
+ if len(non_none) != 1:
1979
+ raise ValueError("Exactly one of person_ids, company_ids, or opportunity_ids required")
1980
+
1981
+ name, ids = non_none[0]
1982
+ semaphore = asyncio.Semaphore(concurrency) if concurrency else None
1983
+
1984
+ async def fetch_one(
1985
+ entity_id: PersonId | CompanyId | OpportunityId,
1986
+ ) -> tuple[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue] | None]:
1987
+ async def do_fetch() -> builtins.list[FieldValue]:
1988
+ if name == "person_ids":
1989
+ return await self.list(person_id=cast(PersonId, entity_id))
1990
+ elif name == "company_ids":
1991
+ return await self.list(company_id=cast(CompanyId, entity_id))
1992
+ else:
1993
+ return await self.list(opportunity_id=cast(OpportunityId, entity_id))
1994
+
1995
+ try:
1996
+ if semaphore:
1997
+ async with semaphore:
1998
+ values = await do_fetch()
1999
+ else:
2000
+ values = await do_fetch()
2001
+ return (entity_id, values)
2002
+ except AffinityError:
2003
+ if on_error == "raise":
2004
+ raise
2005
+ return (entity_id, None)
2006
+ except Exception as e:
2007
+ if on_error == "raise":
2008
+ status_code = getattr(e, "status_code", None)
2009
+ raise AffinityError(
2010
+ f"Failed to get field values for {name[:-1]} {entity_id}: {e}",
2011
+ status_code=status_code,
2012
+ ) from e
2013
+ return (entity_id, None)
2014
+
2015
+ results = await asyncio.gather(*[fetch_one(eid) for eid in ids])
2016
+ return {eid: values for eid, values in results if values is not None}
2017
+
2018
+
2019
+ class AsyncFieldValueChangesService:
2020
+ """Async service for querying field value change history (V1 API)."""
2021
+
2022
+ def __init__(self, client: AsyncHTTPClient):
2023
+ self._client = client
2024
+
2025
+ @staticmethod
2026
+ def _validate_selector(
2027
+ *,
2028
+ person_id: PersonId | None,
2029
+ company_id: CompanyId | None,
2030
+ opportunity_id: OpportunityId | None,
2031
+ list_entry_id: ListEntryId | None,
2032
+ ) -> None:
2033
+ provided = [
2034
+ name
2035
+ for name, value in (
2036
+ ("person_id", person_id),
2037
+ ("company_id", company_id),
2038
+ ("opportunity_id", opportunity_id),
2039
+ ("list_entry_id", list_entry_id),
2040
+ )
2041
+ if value is not None
2042
+ ]
2043
+ if len(provided) != 1:
2044
+ joined = ", ".join(provided) if provided else "(none)"
2045
+ raise ValueError(
2046
+ "FieldValueChangesService.list() requires exactly one of: "
2047
+ "person_id, company_id, opportunity_id, or list_entry_id; "
2048
+ f"got {len(provided)}: {joined}"
2049
+ )
2050
+
2051
+ async def list(
2052
+ self,
2053
+ field_id: AnyFieldId,
2054
+ *,
2055
+ person_id: PersonId | None = None,
2056
+ company_id: CompanyId | None = None,
2057
+ opportunity_id: OpportunityId | None = None,
2058
+ list_entry_id: ListEntryId | None = None,
2059
+ action_type: FieldValueChangeAction | None = None,
2060
+ ) -> builtins.list[FieldValueChange]:
2061
+ """
2062
+ Get field value changes for a specific field and entity.
2063
+
2064
+ This endpoint is not paginated. For large histories, use narrow filters.
2065
+ V1 requires numeric field IDs; only `field-<digits>` values are convertible.
2066
+ """
2067
+ self._validate_selector(
2068
+ person_id=person_id,
2069
+ company_id=company_id,
2070
+ opportunity_id=opportunity_id,
2071
+ list_entry_id=list_entry_id,
2072
+ )
2073
+
2074
+ params: dict[str, Any] = {
2075
+ "field_id": field_id_to_v1_numeric(field_id),
2076
+ }
2077
+ if person_id is not None:
2078
+ params["person_id"] = int(person_id)
2079
+ if company_id is not None:
2080
+ params["organization_id"] = int(company_id)
2081
+ if opportunity_id is not None:
2082
+ params["opportunity_id"] = int(opportunity_id)
2083
+ if list_entry_id is not None:
2084
+ params["list_entry_id"] = int(list_entry_id)
2085
+ if action_type is not None:
2086
+ params["action_type"] = int(action_type)
2087
+
2088
+ data = await self._client.get("/field-value-changes", params=params, v1=True)
2089
+ items = data.get("data", [])
2090
+ if not isinstance(items, list):
2091
+ items = []
2092
+ return [FieldValueChange.model_validate(item) for item in items]
2093
+
2094
+ async def iter(
2095
+ self,
2096
+ field_id: AnyFieldId,
2097
+ *,
2098
+ person_id: PersonId | None = None,
2099
+ company_id: CompanyId | None = None,
2100
+ opportunity_id: OpportunityId | None = None,
2101
+ list_entry_id: ListEntryId | None = None,
2102
+ action_type: FieldValueChangeAction | None = None,
2103
+ ) -> AsyncIterator[FieldValueChange]:
2104
+ """Iterate field value changes (convenience wrapper for list())."""
2105
+ for item in await self.list(
2106
+ field_id,
2107
+ person_id=person_id,
2108
+ company_id=company_id,
2109
+ opportunity_id=opportunity_id,
2110
+ list_entry_id=list_entry_id,
2111
+ action_type=action_type,
2112
+ ):
2113
+ yield item
2114
+
2115
+
2116
+ class AsyncRelationshipStrengthService:
2117
+ """Async service for querying relationship strengths (V1 API)."""
2118
+
2119
+ def __init__(self, client: AsyncHTTPClient):
2120
+ self._client = client
2121
+
2122
+ async def get(
2123
+ self,
2124
+ external_id: PersonId,
2125
+ internal_id: UserId | None = None,
2126
+ ) -> builtins.list[RelationshipStrength]:
2127
+ params: dict[str, Any] = {"external_id": int(external_id)}
2128
+ if internal_id:
2129
+ params["internal_id"] = int(internal_id)
2130
+
2131
+ data = await self._client.get("/relationships-strengths", params=params, v1=True)
2132
+ items = data.get("data", [])
2133
+ if not isinstance(items, list):
2134
+ items = []
2135
+ return [RelationshipStrength.model_validate(r) for r in items]
2136
+
2137
+
2138
+ class AsyncEntityFileService:
2139
+ """Async service for managing files attached to entities (V1 API)."""
2140
+
2141
+ def __init__(self, client: AsyncHTTPClient):
2142
+ self._client = client
2143
+
2144
+ def _validate_exactly_one_target(
2145
+ self,
2146
+ *,
2147
+ person_id: PersonId | None,
2148
+ company_id: CompanyId | None,
2149
+ opportunity_id: OpportunityId | None,
2150
+ ) -> None:
2151
+ targets = [person_id, company_id, opportunity_id]
2152
+ count = sum(1 for t in targets if t is not None)
2153
+ if count == 1:
2154
+ return
2155
+ if count == 0:
2156
+ raise ValueError("Exactly one of person_id, company_id, or opportunity_id is required")
2157
+ raise ValueError("Only one of person_id, company_id, or opportunity_id may be provided")
2158
+
2159
+ async def list(
2160
+ self,
2161
+ *,
2162
+ person_id: PersonId | None = None,
2163
+ company_id: CompanyId | None = None,
2164
+ opportunity_id: OpportunityId | None = None,
2165
+ page_size: int | None = None,
2166
+ page_token: str | None = None,
2167
+ ) -> V1PaginatedResponse[EntityFile]:
2168
+ self._validate_exactly_one_target(
2169
+ person_id=person_id,
2170
+ company_id=company_id,
2171
+ opportunity_id=opportunity_id,
2172
+ )
2173
+ params: dict[str, Any] = {}
2174
+ if person_id:
2175
+ params["person_id"] = int(person_id)
2176
+ if company_id:
2177
+ params["organization_id"] = int(company_id)
2178
+ if opportunity_id:
2179
+ params["opportunity_id"] = int(opportunity_id)
2180
+ if page_size:
2181
+ params["page_size"] = page_size
2182
+ if page_token:
2183
+ params["page_token"] = page_token
2184
+
2185
+ data = await self._client.get("/entity-files", params=params or None, v1=True)
2186
+ items = (
2187
+ data.get("entity_files")
2188
+ or data.get("entityFiles")
2189
+ or data.get("files")
2190
+ or data.get("data", [])
2191
+ )
2192
+ if not isinstance(items, list):
2193
+ items = []
2194
+ return V1PaginatedResponse[EntityFile](
2195
+ data=[EntityFile.model_validate(f) for f in items],
2196
+ next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
2197
+ )
2198
+
2199
+ async def get(self, file_id: FileId) -> EntityFile:
2200
+ data = await self._client.get(f"/entity-files/{file_id}", v1=True)
2201
+ return EntityFile.model_validate(data)
2202
+
2203
+ async def download(
2204
+ self,
2205
+ file_id: FileId,
2206
+ *,
2207
+ timeout: httpx.Timeout | float | None = None,
2208
+ deadline_seconds: float | None = None,
2209
+ ) -> bytes:
2210
+ return await self._client.download_file(
2211
+ f"/entity-files/download/{file_id}",
2212
+ v1=True,
2213
+ timeout=timeout,
2214
+ deadline_seconds=deadline_seconds,
2215
+ )
2216
+
2217
+ def download_stream(
2218
+ self,
2219
+ file_id: FileId,
2220
+ *,
2221
+ chunk_size: int = 65_536,
2222
+ on_progress: ProgressCallback | None = None,
2223
+ timeout: httpx.Timeout | float | None = None,
2224
+ deadline_seconds: float | None = None,
2225
+ ) -> AsyncIterator[bytes]:
2226
+ return self._client.stream_download(
2227
+ f"/entity-files/download/{file_id}",
2228
+ v1=True,
2229
+ chunk_size=chunk_size,
2230
+ on_progress=on_progress,
2231
+ timeout=timeout,
2232
+ deadline_seconds=deadline_seconds,
2233
+ )
2234
+
2235
+ async def download_stream_with_info(
2236
+ self,
2237
+ file_id: FileId,
2238
+ *,
2239
+ chunk_size: int = 65_536,
2240
+ on_progress: ProgressCallback | None = None,
2241
+ timeout: httpx.Timeout | float | None = None,
2242
+ deadline_seconds: float | None = None,
2243
+ ) -> AsyncDownloadedFile:
2244
+ """
2245
+ Stream-download a file and return response metadata (headers/filename/size).
2246
+
2247
+ Notes:
2248
+ - `filename` is derived from `Content-Disposition` when present.
2249
+ - If the server does not provide a filename, callers can fall back to
2250
+ `await files.get(file_id)` and use `.name`.
2251
+ """
2252
+ return await self._client.stream_download_with_info(
2253
+ f"/entity-files/download/{file_id}",
2254
+ v1=True,
2255
+ chunk_size=chunk_size,
2256
+ on_progress=on_progress,
2257
+ timeout=timeout,
2258
+ deadline_seconds=deadline_seconds,
2259
+ )
2260
+
2261
+ async def download_to(
2262
+ self,
2263
+ file_id: FileId,
2264
+ path: str | Path,
2265
+ *,
2266
+ overwrite: bool = False,
2267
+ chunk_size: int = 65_536,
2268
+ on_progress: ProgressCallback | None = None,
2269
+ timeout: httpx.Timeout | float | None = None,
2270
+ deadline_seconds: float | None = None,
2271
+ ) -> Path:
2272
+ target = Path(path)
2273
+ if target.exists() and not overwrite:
2274
+ raise FileExistsError(str(target))
2275
+
2276
+ with target.open("wb") as f:
2277
+ async for chunk in self.download_stream(
2278
+ file_id,
2279
+ chunk_size=chunk_size,
2280
+ on_progress=on_progress,
2281
+ timeout=timeout,
2282
+ deadline_seconds=deadline_seconds,
2283
+ ):
2284
+ f.write(chunk)
2285
+
2286
+ return target
2287
+
2288
+ async def upload(
2289
+ self,
2290
+ files: dict[str, Any],
2291
+ *,
2292
+ person_id: PersonId | None = None,
2293
+ company_id: CompanyId | None = None,
2294
+ opportunity_id: OpportunityId | None = None,
2295
+ ) -> bool:
2296
+ self._validate_exactly_one_target(
2297
+ person_id=person_id,
2298
+ company_id=company_id,
2299
+ opportunity_id=opportunity_id,
2300
+ )
2301
+ data: dict[str, Any] = {}
2302
+ if person_id:
2303
+ data["person_id"] = int(person_id)
2304
+ if company_id:
2305
+ data["organization_id"] = int(company_id)
2306
+ if opportunity_id:
2307
+ data["opportunity_id"] = int(opportunity_id)
2308
+
2309
+ result = await self._client.upload_file(
2310
+ "/entity-files",
2311
+ files=files,
2312
+ data=data,
2313
+ v1=True,
2314
+ )
2315
+ if "success" in result:
2316
+ return bool(result.get("success"))
2317
+ return True
2318
+
2319
+ async def upload_path(
2320
+ self,
2321
+ path: str | Path,
2322
+ *,
2323
+ person_id: PersonId | None = None,
2324
+ company_id: CompanyId | None = None,
2325
+ opportunity_id: OpportunityId | None = None,
2326
+ filename: str | None = None,
2327
+ content_type: str | None = None,
2328
+ on_progress: ProgressCallback | None = None,
2329
+ ) -> bool:
2330
+ self._validate_exactly_one_target(
2331
+ person_id=person_id,
2332
+ company_id=company_id,
2333
+ opportunity_id=opportunity_id,
2334
+ )
2335
+
2336
+ p = Path(path)
2337
+ upload_filename = filename or p.name
2338
+ guessed, _ = mimetypes.guess_type(upload_filename)
2339
+ final_content_type = content_type or guessed or "application/octet-stream"
2340
+ total = p.stat().st_size
2341
+
2342
+ if on_progress:
2343
+ on_progress(0, total, phase="upload")
2344
+
2345
+ with p.open("rb") as f:
2346
+ ok = await self.upload(
2347
+ files={"file": (upload_filename, f, final_content_type)},
2348
+ person_id=person_id,
2349
+ company_id=company_id,
2350
+ opportunity_id=opportunity_id,
2351
+ )
2352
+
2353
+ if on_progress:
2354
+ on_progress(total, total, phase="upload")
2355
+
2356
+ return ok
2357
+
2358
+ async def upload_bytes(
2359
+ self,
2360
+ data: bytes,
2361
+ filename: str,
2362
+ *,
2363
+ person_id: PersonId | None = None,
2364
+ company_id: CompanyId | None = None,
2365
+ opportunity_id: OpportunityId | None = None,
2366
+ content_type: str | None = None,
2367
+ on_progress: ProgressCallback | None = None,
2368
+ ) -> bool:
2369
+ self._validate_exactly_one_target(
2370
+ person_id=person_id,
2371
+ company_id=company_id,
2372
+ opportunity_id=opportunity_id,
2373
+ )
2374
+
2375
+ guessed, _ = mimetypes.guess_type(filename)
2376
+ final_content_type = content_type or guessed or "application/octet-stream"
2377
+ total = len(data)
2378
+
2379
+ if on_progress:
2380
+ on_progress(0, total, phase="upload")
2381
+
2382
+ ok = await self.upload(
2383
+ files={"file": (filename, data, final_content_type)},
2384
+ person_id=person_id,
2385
+ company_id=company_id,
2386
+ opportunity_id=opportunity_id,
2387
+ )
2388
+
2389
+ if on_progress:
2390
+ on_progress(total, total, phase="upload")
2391
+
2392
+ return ok
2393
+
2394
+ async def all(
2395
+ self,
2396
+ *,
2397
+ person_id: PersonId | None = None,
2398
+ company_id: CompanyId | None = None,
2399
+ opportunity_id: OpportunityId | None = None,
2400
+ ) -> AsyncIterator[EntityFile]:
2401
+ self._validate_exactly_one_target(
2402
+ person_id=person_id,
2403
+ company_id=company_id,
2404
+ opportunity_id=opportunity_id,
2405
+ )
2406
+
2407
+ page_token: str | None = None
2408
+ while True:
2409
+ page = await self.list(
2410
+ person_id=person_id,
2411
+ company_id=company_id,
2412
+ opportunity_id=opportunity_id,
2413
+ page_token=page_token,
2414
+ )
2415
+ for item in page.data:
2416
+ yield item
2417
+ if not page.has_next:
2418
+ break
2419
+ page_token = page.next_page_token
2420
+
2421
+ def iter(
2422
+ self,
2423
+ *,
2424
+ person_id: PersonId | None = None,
2425
+ company_id: CompanyId | None = None,
2426
+ opportunity_id: OpportunityId | None = None,
2427
+ ) -> AsyncIterator[EntityFile]:
2428
+ return self.all(
2429
+ person_id=person_id,
2430
+ company_id=company_id,
2431
+ opportunity_id=opportunity_id,
2432
+ )
2433
+
2434
+
2435
+ class AsyncAuthService:
2436
+ """Async service for authentication info."""
2437
+
2438
+ def __init__(self, client: AsyncHTTPClient):
2439
+ self._client = client
2440
+
2441
+ async def whoami(self) -> WhoAmI:
2442
+ data = await self._client.get("/auth/whoami")
2443
+ return WhoAmI.model_validate(data)
2444
+
2445
+ # Note: rate limit handling is exposed via `client.rate_limits` (version-agnostic).