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,1348 @@
1
+ """
2
+ Person service.
3
+
4
+ Provides operations for managing persons (contacts) in Affinity.
5
+ Uses V2 API for reading, V1 API for writing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import builtins
11
+ from collections.abc import AsyncIterator, Iterator, Sequence
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from ..exceptions import BetaEndpointDisabledError
15
+ from ..filters import FilterExpression
16
+ from ..models.entities import (
17
+ FieldMetadata,
18
+ ListEntry,
19
+ ListSummary,
20
+ Person,
21
+ PersonCreate,
22
+ PersonUpdate,
23
+ )
24
+ from ..models.pagination import (
25
+ AsyncPageIterator,
26
+ PageIterator,
27
+ PaginatedResponse,
28
+ PaginationInfo,
29
+ V1PaginatedResponse,
30
+ )
31
+ from ..models.secondary import MergeTask
32
+ from ..models.types import AnyFieldId, CompanyId, FieldType, OpportunityId, PersonId
33
+
34
+ if TYPE_CHECKING:
35
+ from ..clients.http import AsyncHTTPClient, HTTPClient
36
+
37
+
38
+ def _person_matches(person: Person, *, email: str | None, name: str | None) -> bool:
39
+ if email:
40
+ email_lower = email.lower()
41
+ if person.primary_email and person.primary_email.lower() == email_lower:
42
+ return True
43
+ if person.emails:
44
+ for addr in person.emails:
45
+ if addr.lower() == email_lower:
46
+ return True
47
+ if name:
48
+ name_lower = name.lower()
49
+ full_name = f"{person.first_name or ''} {person.last_name or ''}".strip()
50
+ if full_name.lower() == name_lower:
51
+ return True
52
+ return False
53
+
54
+
55
+ # V1 → V2 key mapping for person responses
56
+ _V1_TO_V2_KEYS = {
57
+ "first_name": "firstName",
58
+ "last_name": "lastName",
59
+ "primary_email_address": "primaryEmailAddress",
60
+ "email_addresses": "emailAddresses",
61
+ "organization_ids": "organizationIds",
62
+ "opportunity_ids": "opportunityIds",
63
+ "current_organization_ids": "currentOrganizationIds",
64
+ "list_entry_id": "listEntryId",
65
+ "interaction_dates": "interactionDates",
66
+ "created_at": "createdAt",
67
+ "updated_at": "updatedAt",
68
+ }
69
+
70
+
71
+ def _normalize_v1_person_response(data: dict[str, Any]) -> dict[str, Any]:
72
+ """
73
+ Normalize V1 person response to match V2 schema.
74
+
75
+ V1 uses snake_case (first_name), V2 uses camelCase (firstName).
76
+ The Person model uses aliases, so we need consistent key names.
77
+
78
+ Implementation notes:
79
+ - Maps snake_case keys to camelCase as needed
80
+ - Strips unknown keys to avoid Pydantic strict mode errors
81
+ - Handles nested field_values entries appropriately
82
+ - V1 may include field values for deleted fields; these are preserved
83
+ """
84
+ result: dict[str, Any] = {}
85
+
86
+ for key, value in data.items():
87
+ # Skip field_values - handled separately
88
+ if key == "field_values":
89
+ continue
90
+
91
+ # Map known V1 keys to V2
92
+ if key in _V1_TO_V2_KEYS:
93
+ result[_V1_TO_V2_KEYS[key]] = value
94
+ else:
95
+ # Keep as-is (id, type, emails, etc. are same in both)
96
+ result[key] = value
97
+
98
+ return result
99
+
100
+
101
+ class PersonService:
102
+ """
103
+ Service for managing persons (contacts).
104
+
105
+ Uses V2 API for efficient reading with field selection,
106
+ V1 API for create/update/delete operations.
107
+ """
108
+
109
+ def __init__(self, client: HTTPClient):
110
+ self._client = client
111
+
112
+ # =========================================================================
113
+ # Read Operations (V2 API)
114
+ # =========================================================================
115
+
116
+ def list(
117
+ self,
118
+ *,
119
+ ids: Sequence[PersonId] | None = None,
120
+ field_ids: Sequence[AnyFieldId] | None = None,
121
+ field_types: Sequence[FieldType] | None = None,
122
+ filter: str | FilterExpression | None = None,
123
+ limit: int | None = None,
124
+ cursor: str | None = None,
125
+ ) -> PaginatedResponse[Person]:
126
+ """
127
+ Get a page of persons.
128
+
129
+ Args:
130
+ ids: Specific person IDs to fetch (batch lookup)
131
+ field_ids: Specific field IDs to include in response
132
+ field_types: Field types to include
133
+ filter: V2 filter expression string, or a FilterExpression built via `affinity.F`
134
+ limit: Maximum number of results
135
+ cursor: Cursor to resume pagination (opaque; obtained from prior responses)
136
+
137
+ Returns:
138
+ Paginated response with persons
139
+ """
140
+ if cursor is not None:
141
+ if any(p is not None for p in (ids, field_ids, field_types, filter, limit)):
142
+ raise ValueError(
143
+ "Cannot combine 'cursor' with other parameters; cursor encodes all query "
144
+ "context. Start a new pagination sequence without a cursor to change "
145
+ "parameters."
146
+ )
147
+ data = self._client.get_url(cursor)
148
+ else:
149
+ params: dict[str, Any] = {}
150
+ if ids:
151
+ params["ids"] = [int(id_) for id_ in ids]
152
+ if field_ids:
153
+ params["fieldIds"] = [str(field_id) for field_id in field_ids]
154
+ if field_types:
155
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
156
+ if filter is not None:
157
+ filter_text = str(filter).strip()
158
+ if filter_text:
159
+ params["filter"] = filter_text
160
+ if limit:
161
+ params["limit"] = limit
162
+ data = self._client.get("/persons", params=params or None)
163
+
164
+ return PaginatedResponse[Person](
165
+ data=[Person.model_validate(p) for p in data.get("data", [])],
166
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
167
+ )
168
+
169
+ def pages(
170
+ self,
171
+ *,
172
+ ids: Sequence[PersonId] | None = None,
173
+ field_ids: Sequence[AnyFieldId] | None = None,
174
+ field_types: Sequence[FieldType] | None = None,
175
+ filter: str | FilterExpression | None = None,
176
+ limit: int | None = None,
177
+ cursor: str | None = None,
178
+ ) -> Iterator[PaginatedResponse[Person]]:
179
+ """
180
+ Iterate person pages (not items), yielding `PaginatedResponse[Person]`.
181
+
182
+ Useful for ETL scripts that need checkpoint/resume via `page.next_cursor`.
183
+
184
+ Args:
185
+ ids: Specific person IDs to fetch (batch lookup)
186
+ field_ids: Specific field IDs to include in response
187
+ field_types: Field types to include
188
+ filter: V2 filter expression string or FilterExpression
189
+ limit: Maximum results per page
190
+ cursor: Cursor to resume pagination
191
+
192
+ Yields:
193
+ PaginatedResponse[Person] for each page
194
+ """
195
+ other_params = (ids, field_ids, field_types, filter, limit)
196
+ if cursor is not None and any(p is not None for p in other_params):
197
+ raise ValueError(
198
+ "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
199
+ "Start a new pagination sequence without a cursor to change parameters."
200
+ )
201
+ requested_cursor = cursor
202
+ page = (
203
+ self.list(cursor=cursor)
204
+ if cursor is not None
205
+ else self.list(
206
+ ids=ids, field_ids=field_ids, field_types=field_types, filter=filter, limit=limit
207
+ )
208
+ )
209
+ while True:
210
+ yield page
211
+ if not page.has_next:
212
+ return
213
+ next_cursor = page.next_cursor
214
+ if next_cursor is None or next_cursor == requested_cursor:
215
+ return
216
+ requested_cursor = next_cursor
217
+ page = self.list(cursor=next_cursor)
218
+
219
+ def all(
220
+ self,
221
+ *,
222
+ ids: Sequence[PersonId] | None = None,
223
+ field_ids: Sequence[AnyFieldId] | None = None,
224
+ field_types: Sequence[FieldType] | None = None,
225
+ filter: str | FilterExpression | None = None,
226
+ ) -> Iterator[Person]:
227
+ """
228
+ Iterate through all persons with automatic pagination.
229
+
230
+ Args:
231
+ ids: Specific person IDs to fetch (batch lookup)
232
+ field_ids: Specific field IDs to include
233
+ field_types: Field types to include
234
+ filter: V2 filter expression
235
+
236
+ Yields:
237
+ Person objects
238
+ """
239
+
240
+ def fetch_page(next_url: str | None) -> PaginatedResponse[Person]:
241
+ if next_url:
242
+ data = self._client.get_url(next_url)
243
+ return PaginatedResponse[Person](
244
+ data=[Person.model_validate(p) for p in data.get("data", [])],
245
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
246
+ )
247
+ return self.list(
248
+ ids=ids,
249
+ field_ids=field_ids,
250
+ field_types=field_types,
251
+ filter=filter,
252
+ )
253
+
254
+ return PageIterator(fetch_page)
255
+
256
+ def iter(
257
+ self,
258
+ *,
259
+ ids: Sequence[PersonId] | None = None,
260
+ field_ids: Sequence[AnyFieldId] | None = None,
261
+ field_types: Sequence[FieldType] | None = None,
262
+ filter: str | FilterExpression | None = None,
263
+ ) -> Iterator[Person]:
264
+ """
265
+ Auto-paginate all persons.
266
+
267
+ Alias for `all()` (FR-006 public contract).
268
+ """
269
+ return self.all(ids=ids, field_ids=field_ids, field_types=field_types, filter=filter)
270
+
271
+ def get(
272
+ self,
273
+ person_id: PersonId,
274
+ *,
275
+ field_ids: Sequence[AnyFieldId] | None = None,
276
+ field_types: Sequence[FieldType] | None = None,
277
+ include_field_values: bool = False,
278
+ ) -> Person:
279
+ """
280
+ Get a single person by ID.
281
+
282
+ Args:
283
+ person_id: The person ID
284
+ field_ids: Specific field IDs to include (V2 API)
285
+ field_types: Field types to include (V2 API)
286
+ include_field_values: If True, use V1 API to fetch embedded field values.
287
+ This saves one API call when you need both person info and field values.
288
+ Note: V1 response is ~2-3x larger than V2; consider this when fetching
289
+ many persons. V1 may include field values for deleted fields.
290
+
291
+ Returns:
292
+ Person object with requested field data.
293
+ When include_field_values=True, the Person will have a `field_values`
294
+ attribute containing the list of FieldValue objects.
295
+ """
296
+ if include_field_values:
297
+ # Use V1 API which returns field values embedded
298
+ data = self._client.get(f"/persons/{person_id}", v1=True)
299
+
300
+ # Extract field_values before normalization
301
+ field_values_data = data.get("field_values", [])
302
+
303
+ # Normalize V1 response to V2 schema
304
+ normalized = _normalize_v1_person_response(data)
305
+ person = Person.model_validate(normalized)
306
+
307
+ # Attach field_values as an attribute
308
+ # Using object.__setattr__ to bypass Pydantic's frozen/immutable if needed
309
+ object.__setattr__(person, "field_values", field_values_data)
310
+
311
+ return person
312
+
313
+ # Standard V2 path
314
+ params: dict[str, Any] = {}
315
+ if field_ids:
316
+ params["fieldIds"] = [str(field_id) for field_id in field_ids]
317
+ if field_types:
318
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
319
+
320
+ data = self._client.get(
321
+ f"/persons/{person_id}",
322
+ params=params or None,
323
+ )
324
+ return Person.model_validate(data)
325
+
326
+ def get_list_entries(
327
+ self,
328
+ person_id: PersonId,
329
+ ) -> PaginatedResponse[ListEntry]:
330
+ """Get all list entries for a person across all lists."""
331
+ data = self._client.get(f"/persons/{person_id}/list-entries")
332
+
333
+ return PaginatedResponse[ListEntry](
334
+ data=[ListEntry.model_validate(e) for e in data.get("data", [])],
335
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
336
+ )
337
+
338
+ def get_lists(
339
+ self,
340
+ person_id: PersonId,
341
+ ) -> PaginatedResponse[ListSummary]:
342
+ """Get all lists that contain this person."""
343
+ data = self._client.get(f"/persons/{person_id}/lists")
344
+
345
+ return PaginatedResponse[ListSummary](
346
+ data=[ListSummary.model_validate(item) for item in data.get("data", [])],
347
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
348
+ )
349
+
350
+ def get_fields(
351
+ self,
352
+ *,
353
+ field_types: Sequence[FieldType] | None = None,
354
+ ) -> builtins.list[FieldMetadata]:
355
+ """
356
+ Get metadata about person fields.
357
+
358
+ Cached for performance.
359
+ """
360
+ params: dict[str, Any] = {}
361
+ if field_types:
362
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
363
+
364
+ data = self._client.get(
365
+ "/persons/fields",
366
+ params=params or None,
367
+ cache_key=f"person_fields:{','.join(field_types or [])}",
368
+ cache_ttl=300,
369
+ )
370
+
371
+ return [FieldMetadata.model_validate(f) for f in data.get("data", [])]
372
+
373
+ # =========================================================================
374
+ # Associations (V1 API)
375
+ # =========================================================================
376
+
377
+ def get_associated_company_ids(
378
+ self,
379
+ person_id: PersonId,
380
+ *,
381
+ max_results: int | None = None,
382
+ ) -> builtins.list[CompanyId]:
383
+ """
384
+ Get associated company IDs for a person.
385
+
386
+ V1-only: V2 does not expose person -> company associations directly.
387
+ Uses GET `/persons/{id}` (V1) and returns `organization_ids`.
388
+
389
+ Args:
390
+ person_id: The person ID
391
+ max_results: Maximum number of company IDs to return
392
+
393
+ Returns:
394
+ List of CompanyId values associated with this person
395
+
396
+ Note:
397
+ The Person model already has `company_ids` populated from V1's
398
+ `organizationIds` field. This method provides API parity with
399
+ `CompanyService.get_associated_person_ids()`.
400
+ """
401
+ data = self._client.get(f"/persons/{person_id}", v1=True)
402
+ # Defensive: handle potential {"person": {...}} wrapper
403
+ # (consistent with CompanyService.get_associated_person_ids pattern)
404
+ person = data.get("person") if isinstance(data, dict) else None
405
+ source = person if isinstance(person, dict) else data
406
+ org_ids = None
407
+ if isinstance(source, dict):
408
+ org_ids = source.get("organization_ids") or source.get("organizationIds")
409
+
410
+ if not isinstance(org_ids, list):
411
+ return []
412
+
413
+ ids = [CompanyId(int(cid)) for cid in org_ids if cid is not None]
414
+ if max_results is not None and max_results >= 0:
415
+ return ids[:max_results]
416
+ return ids
417
+
418
+ def get_associated_opportunity_ids(
419
+ self,
420
+ person_id: PersonId,
421
+ *,
422
+ max_results: int | None = None,
423
+ ) -> builtins.list[OpportunityId]:
424
+ """
425
+ Get associated opportunity IDs for a person.
426
+
427
+ V1-only: V2 does not expose person -> opportunity associations directly.
428
+ Uses GET `/persons/{id}` (V1) and returns `opportunity_ids`.
429
+
430
+ Args:
431
+ person_id: The person ID
432
+ max_results: Maximum number of opportunity IDs to return
433
+
434
+ Returns:
435
+ List of OpportunityId values associated with this person
436
+
437
+ Note:
438
+ The Person model already has `opportunity_ids` populated from V1's
439
+ `opportunityIds` field. This method provides API parity with
440
+ `OpportunityService.get_associated_person_ids()`.
441
+ """
442
+ data = self._client.get(f"/persons/{person_id}", v1=True)
443
+ # Defensive: handle potential {"person": {...}} wrapper
444
+ person = data.get("person") if isinstance(data, dict) else None
445
+ source = person if isinstance(person, dict) else data
446
+ opp_ids = None
447
+ if isinstance(source, dict):
448
+ opp_ids = source.get("opportunity_ids") or source.get("opportunityIds")
449
+
450
+ if not isinstance(opp_ids, list):
451
+ return []
452
+
453
+ ids = [OpportunityId(int(oid)) for oid in opp_ids if oid is not None]
454
+ if max_results is not None and max_results >= 0:
455
+ return ids[:max_results]
456
+ return ids
457
+
458
+ # =========================================================================
459
+ # Search (V1 API)
460
+ # =========================================================================
461
+
462
+ def search(
463
+ self,
464
+ term: str,
465
+ *,
466
+ with_interaction_dates: bool = False,
467
+ with_interaction_persons: bool = False,
468
+ with_opportunities: bool = False,
469
+ page_size: int | None = None,
470
+ page_token: str | None = None,
471
+ ) -> V1PaginatedResponse[Person]:
472
+ """
473
+ Search for persons by name or email.
474
+
475
+ Uses V1 API for search functionality.
476
+
477
+ Args:
478
+ term: Search term (name or email)
479
+ with_interaction_dates: Include interaction date data
480
+ with_interaction_persons: Include persons for interactions
481
+ with_opportunities: Include associated opportunity IDs
482
+ page_size: Results per page (max 500)
483
+ page_token: Pagination token
484
+
485
+ Returns:
486
+ Dict with 'persons' and 'next_page_token'
487
+ """
488
+ params: dict[str, Any] = {"term": term}
489
+ if with_interaction_dates:
490
+ params["with_interaction_dates"] = True
491
+ if with_interaction_persons:
492
+ params["with_interaction_persons"] = True
493
+ if with_opportunities:
494
+ params["with_opportunities"] = True
495
+ if page_size:
496
+ params["page_size"] = page_size
497
+ if page_token:
498
+ params["page_token"] = page_token
499
+
500
+ data = self._client.get("/persons", params=params, v1=True)
501
+ items = [Person.model_validate(p) for p in data.get("persons", [])]
502
+ return V1PaginatedResponse[Person](
503
+ data=items,
504
+ next_page_token=data.get("next_page_token"),
505
+ )
506
+
507
+ def search_pages(
508
+ self,
509
+ term: str,
510
+ *,
511
+ with_interaction_dates: bool = False,
512
+ with_interaction_persons: bool = False,
513
+ with_opportunities: bool = False,
514
+ page_size: int | None = None,
515
+ page_token: str | None = None,
516
+ ) -> Iterator[V1PaginatedResponse[Person]]:
517
+ """
518
+ Iterate V1 person-search result pages.
519
+
520
+ Useful for scripts that need checkpoint/resume via `next_page_token`.
521
+
522
+ Args:
523
+ term: Search term (name or email)
524
+ with_interaction_dates: Include interaction date data
525
+ with_interaction_persons: Include persons for interactions
526
+ with_opportunities: Include associated opportunity IDs
527
+ page_size: Results per page (max 500)
528
+ page_token: Resume from this pagination token
529
+
530
+ Yields:
531
+ V1PaginatedResponse[Person] for each page
532
+ """
533
+ requested_token = page_token
534
+ page = self.search(
535
+ term,
536
+ with_interaction_dates=with_interaction_dates,
537
+ with_interaction_persons=with_interaction_persons,
538
+ with_opportunities=with_opportunities,
539
+ page_size=page_size,
540
+ page_token=page_token,
541
+ )
542
+ while True:
543
+ yield page
544
+ next_token = page.next_page_token
545
+ if not next_token or next_token == requested_token:
546
+ return
547
+ requested_token = next_token
548
+ page = self.search(
549
+ term,
550
+ with_interaction_dates=with_interaction_dates,
551
+ with_interaction_persons=with_interaction_persons,
552
+ with_opportunities=with_opportunities,
553
+ page_size=page_size,
554
+ page_token=next_token,
555
+ )
556
+
557
+ def search_all(
558
+ self,
559
+ term: str,
560
+ *,
561
+ with_interaction_dates: bool = False,
562
+ with_interaction_persons: bool = False,
563
+ with_opportunities: bool = False,
564
+ page_size: int | None = None,
565
+ page_token: str | None = None,
566
+ ) -> Iterator[Person]:
567
+ """
568
+ Iterate all V1 person-search results with automatic pagination.
569
+
570
+ Args:
571
+ term: Search term (name or email)
572
+ with_interaction_dates: Include interaction date data
573
+ with_interaction_persons: Include persons for interactions
574
+ with_opportunities: Include associated opportunity IDs
575
+ page_size: Results per page (max 500)
576
+ page_token: Resume from this pagination token
577
+
578
+ Yields:
579
+ Person objects matching the search term
580
+ """
581
+ for page in self.search_pages(
582
+ term,
583
+ with_interaction_dates=with_interaction_dates,
584
+ with_interaction_persons=with_interaction_persons,
585
+ with_opportunities=with_opportunities,
586
+ page_size=page_size,
587
+ page_token=page_token,
588
+ ):
589
+ yield from page.data
590
+
591
+ def resolve(
592
+ self,
593
+ *,
594
+ email: str | None = None,
595
+ name: str | None = None,
596
+ ) -> Person | None:
597
+ """
598
+ Find a single person by email or name.
599
+
600
+ This is a convenience helper that searches and returns the first exact match,
601
+ or None if not found. Uses V1 search internally.
602
+
603
+ Args:
604
+ email: Email address to search for
605
+ name: Person name to search for (first + last)
606
+
607
+ Returns:
608
+ The matching Person, or None if not found
609
+
610
+ Raises:
611
+ ValueError: If neither email nor name is provided
612
+
613
+ Note:
614
+ This auto-paginates V1 search results until a match is found.
615
+ If multiple matches are found, returns the first one. For full
616
+ disambiguation, use resolve_all() or search() directly.
617
+ """
618
+ if not email and not name:
619
+ raise ValueError("Must provide either email or name")
620
+
621
+ term = email or name or ""
622
+ for page in self.search_pages(term, page_size=10):
623
+ for person in page.data:
624
+ if _person_matches(person, email=email, name=name):
625
+ return person
626
+
627
+ return None
628
+
629
+ def resolve_all(
630
+ self,
631
+ *,
632
+ email: str | None = None,
633
+ name: str | None = None,
634
+ ) -> builtins.list[Person]:
635
+ """
636
+ Find all persons matching an email or name.
637
+
638
+ Notes:
639
+ - This auto-paginates V1 search results to collect exact matches.
640
+ - Unlike resolve(), this returns every match in server-provided order.
641
+ """
642
+ if not email and not name:
643
+ raise ValueError("Must provide either email or name")
644
+
645
+ term = email or name or ""
646
+ matches: builtins.list[Person] = []
647
+ for page in self.search_pages(term, page_size=10):
648
+ for person in page.data:
649
+ if _person_matches(person, email=email, name=name):
650
+ matches.append(person)
651
+ return matches
652
+
653
+ # =========================================================================
654
+ # Write Operations (V1 API)
655
+ # =========================================================================
656
+
657
+ def create(self, data: PersonCreate) -> Person:
658
+ """
659
+ Create a new person.
660
+
661
+ Raises:
662
+ ValidationError: If email conflicts with existing person
663
+ """
664
+ payload = data.model_dump(by_alias=True, mode="json")
665
+ if not data.company_ids:
666
+ payload.pop("organization_ids", None)
667
+
668
+ result = self._client.post("/persons", json=payload, v1=True)
669
+
670
+ if self._client.cache:
671
+ self._client.cache.invalidate_prefix("person")
672
+
673
+ return Person.model_validate(result)
674
+
675
+ def update(
676
+ self,
677
+ person_id: PersonId,
678
+ data: PersonUpdate,
679
+ ) -> Person:
680
+ """
681
+ Update an existing person.
682
+
683
+ Note: To add emails/organizations, include existing values plus new ones.
684
+ """
685
+ payload = data.model_dump(
686
+ by_alias=True,
687
+ mode="json",
688
+ exclude_unset=True,
689
+ exclude_none=True,
690
+ )
691
+
692
+ result = self._client.put(
693
+ f"/persons/{person_id}",
694
+ json=payload,
695
+ v1=True,
696
+ )
697
+
698
+ if self._client.cache:
699
+ self._client.cache.invalidate_prefix("person")
700
+
701
+ return Person.model_validate(result)
702
+
703
+ def delete(self, person_id: PersonId) -> bool:
704
+ """Delete a person (also deletes associated field values)."""
705
+ result = self._client.delete(f"/persons/{person_id}", v1=True)
706
+
707
+ if self._client.cache:
708
+ self._client.cache.invalidate_prefix("person")
709
+
710
+ return bool(result.get("success", False))
711
+
712
+ # =========================================================================
713
+ # Merge Operations (V2 BETA)
714
+ # =========================================================================
715
+
716
+ def merge(
717
+ self,
718
+ primary_id: PersonId,
719
+ duplicate_id: PersonId,
720
+ ) -> str:
721
+ """
722
+ Merge a duplicate person into a primary person.
723
+
724
+ Returns a task URL to check merge status.
725
+ """
726
+ if not self._client.enable_beta_endpoints:
727
+ raise BetaEndpointDisabledError(
728
+ "Person merge is a beta endpoint; set enable_beta_endpoints=True to use it."
729
+ )
730
+ result = self._client.post(
731
+ "/person-merges",
732
+ json={
733
+ "primaryPersonId": int(primary_id),
734
+ "duplicatePersonId": int(duplicate_id),
735
+ },
736
+ )
737
+ return str(result.get("taskUrl", ""))
738
+
739
+ def get_merge_status(self, task_id: str) -> MergeTask:
740
+ """Check the status of a merge operation."""
741
+ data = self._client.get(f"/tasks/person-merges/{task_id}")
742
+ return MergeTask.model_validate(data)
743
+
744
+
745
+ class AsyncPersonService:
746
+ """Async version of PersonService."""
747
+
748
+ def __init__(self, client: AsyncHTTPClient):
749
+ self._client = client
750
+
751
+ async def list(
752
+ self,
753
+ *,
754
+ ids: Sequence[PersonId] | None = None,
755
+ field_ids: Sequence[AnyFieldId] | None = None,
756
+ field_types: Sequence[FieldType] | None = None,
757
+ filter: str | FilterExpression | None = None,
758
+ limit: int | None = None,
759
+ cursor: str | None = None,
760
+ ) -> PaginatedResponse[Person]:
761
+ """
762
+ Get a page of persons.
763
+
764
+ Args:
765
+ ids: Specific person IDs to fetch (batch lookup)
766
+ field_ids: Specific field IDs to include in response
767
+ field_types: Field types to include
768
+ filter: V2 filter expression string, or a FilterExpression built via `affinity.F`
769
+ limit: Maximum number of results
770
+ cursor: Cursor to resume pagination (opaque; obtained from prior responses)
771
+
772
+ Returns:
773
+ Paginated response with persons
774
+ """
775
+ if cursor is not None:
776
+ if any(p is not None for p in (ids, field_ids, field_types, filter, limit)):
777
+ raise ValueError(
778
+ "Cannot combine 'cursor' with other parameters; cursor encodes all query "
779
+ "context. Start a new pagination sequence without a cursor to change "
780
+ "parameters."
781
+ )
782
+ data = await self._client.get_url(cursor)
783
+ else:
784
+ params: dict[str, Any] = {}
785
+ if ids:
786
+ params["ids"] = [int(id_) for id_ in ids]
787
+ if field_ids:
788
+ params["fieldIds"] = [str(field_id) for field_id in field_ids]
789
+ if field_types:
790
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
791
+ if filter is not None:
792
+ filter_text = str(filter).strip()
793
+ if filter_text:
794
+ params["filter"] = filter_text
795
+ if limit:
796
+ params["limit"] = limit
797
+ data = await self._client.get("/persons", params=params or None)
798
+
799
+ return PaginatedResponse[Person](
800
+ data=[Person.model_validate(p) for p in data.get("data", [])],
801
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
802
+ )
803
+
804
+ async def pages(
805
+ self,
806
+ *,
807
+ ids: Sequence[PersonId] | None = None,
808
+ field_ids: Sequence[AnyFieldId] | None = None,
809
+ field_types: Sequence[FieldType] | None = None,
810
+ filter: str | FilterExpression | None = None,
811
+ limit: int | None = None,
812
+ cursor: str | None = None,
813
+ ) -> AsyncIterator[PaginatedResponse[Person]]:
814
+ """
815
+ Iterate person pages (not items), yielding `PaginatedResponse[Person]`.
816
+
817
+ Useful for ETL scripts that need checkpoint/resume via `page.next_cursor`.
818
+
819
+ Args:
820
+ ids: Specific person IDs to fetch (batch lookup)
821
+ field_ids: Specific field IDs to include in response
822
+ field_types: Field types to include
823
+ filter: V2 filter expression string or FilterExpression
824
+ limit: Maximum results per page
825
+ cursor: Cursor to resume pagination
826
+
827
+ Yields:
828
+ PaginatedResponse[Person] for each page
829
+ """
830
+ other_params = (ids, field_ids, field_types, filter, limit)
831
+ if cursor is not None and any(p is not None for p in other_params):
832
+ raise ValueError(
833
+ "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
834
+ "Start a new pagination sequence without a cursor to change parameters."
835
+ )
836
+ requested_cursor = cursor
837
+ if cursor is not None:
838
+ page = await self.list(cursor=cursor)
839
+ else:
840
+ page = await self.list(
841
+ ids=ids,
842
+ field_ids=field_ids,
843
+ field_types=field_types,
844
+ filter=filter,
845
+ limit=limit,
846
+ )
847
+ while True:
848
+ yield page
849
+ if not page.has_next:
850
+ return
851
+ next_cursor = page.next_cursor
852
+ if next_cursor is None or next_cursor == requested_cursor:
853
+ return
854
+ requested_cursor = next_cursor
855
+ page = await self.list(cursor=next_cursor)
856
+
857
+ def all(
858
+ self,
859
+ *,
860
+ ids: Sequence[PersonId] | None = None,
861
+ field_ids: Sequence[AnyFieldId] | None = None,
862
+ field_types: Sequence[FieldType] | None = None,
863
+ filter: str | FilterExpression | None = None,
864
+ ) -> AsyncIterator[Person]:
865
+ """
866
+ Iterate through all persons with automatic pagination.
867
+
868
+ Args:
869
+ ids: Specific person IDs to fetch (batch lookup)
870
+ field_ids: Specific field IDs to include
871
+ field_types: Field types to include
872
+ filter: V2 filter expression
873
+
874
+ Yields:
875
+ Person objects
876
+ """
877
+
878
+ async def fetch_page(next_url: str | None) -> PaginatedResponse[Person]:
879
+ if next_url:
880
+ data = await self._client.get_url(next_url)
881
+ return PaginatedResponse[Person](
882
+ data=[Person.model_validate(p) for p in data.get("data", [])],
883
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
884
+ )
885
+ return await self.list(
886
+ ids=ids, field_ids=field_ids, field_types=field_types, filter=filter
887
+ )
888
+
889
+ return AsyncPageIterator(fetch_page)
890
+
891
+ def iter(
892
+ self,
893
+ *,
894
+ ids: Sequence[PersonId] | None = None,
895
+ field_ids: Sequence[AnyFieldId] | None = None,
896
+ field_types: Sequence[FieldType] | None = None,
897
+ filter: str | FilterExpression | None = None,
898
+ ) -> AsyncIterator[Person]:
899
+ """
900
+ Auto-paginate all persons.
901
+
902
+ Alias for `all()` (FR-006 public contract).
903
+ """
904
+ return self.all(ids=ids, field_ids=field_ids, field_types=field_types, filter=filter)
905
+
906
+ async def get(
907
+ self,
908
+ person_id: PersonId,
909
+ *,
910
+ field_ids: Sequence[AnyFieldId] | None = None,
911
+ field_types: Sequence[FieldType] | None = None,
912
+ include_field_values: bool = False,
913
+ ) -> Person:
914
+ """
915
+ Get a single person by ID.
916
+
917
+ Args:
918
+ person_id: The person ID
919
+ field_ids: Specific field IDs to include (V2 API)
920
+ field_types: Field types to include (V2 API)
921
+ include_field_values: If True, use V1 API to fetch embedded field values.
922
+ This saves one API call when you need both person info and field values.
923
+ Note: V1 response is ~2-3x larger than V2; consider this when fetching
924
+ many persons. V1 may include field values for deleted fields.
925
+
926
+ Returns:
927
+ Person object with requested field data.
928
+ When include_field_values=True, the Person will have a `field_values`
929
+ attribute containing the list of FieldValue objects.
930
+ """
931
+ if include_field_values:
932
+ # Use V1 API which returns field values embedded
933
+ data = await self._client.get(f"/persons/{person_id}", v1=True)
934
+
935
+ # Extract field_values before normalization
936
+ field_values_data = data.get("field_values", [])
937
+
938
+ # Normalize V1 response to V2 schema
939
+ normalized = _normalize_v1_person_response(data)
940
+ person = Person.model_validate(normalized)
941
+
942
+ # Attach field_values as an attribute
943
+ object.__setattr__(person, "field_values", field_values_data)
944
+
945
+ return person
946
+
947
+ # Standard V2 path
948
+ params: dict[str, Any] = {}
949
+ if field_ids:
950
+ params["fieldIds"] = [str(field_id) for field_id in field_ids]
951
+ if field_types:
952
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
953
+
954
+ data = await self._client.get(f"/persons/{person_id}", params=params or None)
955
+ return Person.model_validate(data)
956
+
957
+ async def get_list_entries(
958
+ self,
959
+ person_id: PersonId,
960
+ ) -> PaginatedResponse[ListEntry]:
961
+ """Get all list entries for a person across all lists."""
962
+ data = await self._client.get(f"/persons/{person_id}/list-entries")
963
+
964
+ return PaginatedResponse[ListEntry](
965
+ data=[ListEntry.model_validate(e) for e in data.get("data", [])],
966
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
967
+ )
968
+
969
+ async def get_lists(
970
+ self,
971
+ person_id: PersonId,
972
+ ) -> PaginatedResponse[ListSummary]:
973
+ """Get all lists that contain this person."""
974
+ data = await self._client.get(f"/persons/{person_id}/lists")
975
+
976
+ return PaginatedResponse[ListSummary](
977
+ data=[ListSummary.model_validate(item) for item in data.get("data", [])],
978
+ pagination=PaginationInfo.model_validate(data.get("pagination", {})),
979
+ )
980
+
981
+ async def get_fields(
982
+ self,
983
+ *,
984
+ field_types: Sequence[FieldType] | None = None,
985
+ ) -> builtins.list[FieldMetadata]:
986
+ """
987
+ Get metadata about person fields.
988
+
989
+ Cached for performance.
990
+ """
991
+ params: dict[str, Any] = {}
992
+ if field_types:
993
+ params["fieldTypes"] = [field_type.value for field_type in field_types]
994
+
995
+ data = await self._client.get(
996
+ "/persons/fields",
997
+ params=params or None,
998
+ cache_key=f"person_fields:{','.join(field_types or [])}",
999
+ cache_ttl=300,
1000
+ )
1001
+
1002
+ return [FieldMetadata.model_validate(f) for f in data.get("data", [])]
1003
+
1004
+ # =========================================================================
1005
+ # Associations (V1 API)
1006
+ # =========================================================================
1007
+
1008
+ async def get_associated_company_ids(
1009
+ self,
1010
+ person_id: PersonId,
1011
+ *,
1012
+ max_results: int | None = None,
1013
+ ) -> builtins.list[CompanyId]:
1014
+ """
1015
+ Get associated company IDs for a person.
1016
+
1017
+ V1-only: V2 does not expose person -> company associations directly.
1018
+ Uses GET `/persons/{id}` (V1) and returns `organization_ids`.
1019
+
1020
+ Args:
1021
+ person_id: The person ID
1022
+ max_results: Maximum number of company IDs to return
1023
+
1024
+ Returns:
1025
+ List of CompanyId values associated with this person
1026
+
1027
+ Note:
1028
+ The Person model already has `company_ids` populated from V1's
1029
+ `organizationIds` field. This method provides API parity with
1030
+ `CompanyService.get_associated_person_ids()`.
1031
+ """
1032
+ data = await self._client.get(f"/persons/{person_id}", v1=True)
1033
+ # Defensive: handle potential {"person": {...}} wrapper
1034
+ # (consistent with CompanyService.get_associated_person_ids pattern)
1035
+ person = data.get("person") if isinstance(data, dict) else None
1036
+ source = person if isinstance(person, dict) else data
1037
+ org_ids = None
1038
+ if isinstance(source, dict):
1039
+ org_ids = source.get("organization_ids") or source.get("organizationIds")
1040
+
1041
+ if not isinstance(org_ids, list):
1042
+ return []
1043
+
1044
+ ids = [CompanyId(int(cid)) for cid in org_ids if cid is not None]
1045
+ if max_results is not None and max_results >= 0:
1046
+ return ids[:max_results]
1047
+ return ids
1048
+
1049
+ async def get_associated_opportunity_ids(
1050
+ self,
1051
+ person_id: PersonId,
1052
+ *,
1053
+ max_results: int | None = None,
1054
+ ) -> builtins.list[OpportunityId]:
1055
+ """
1056
+ Get associated opportunity IDs for a person.
1057
+
1058
+ V1-only: V2 does not expose person -> opportunity associations directly.
1059
+ Uses GET `/persons/{id}` (V1) and returns `opportunity_ids`.
1060
+
1061
+ Args:
1062
+ person_id: The person ID
1063
+ max_results: Maximum number of opportunity IDs to return
1064
+
1065
+ Returns:
1066
+ List of OpportunityId values associated with this person
1067
+
1068
+ Note:
1069
+ The Person model already has `opportunity_ids` populated from V1's
1070
+ `opportunityIds` field. This method provides API parity with
1071
+ `OpportunityService.get_associated_person_ids()`.
1072
+ """
1073
+ data = await self._client.get(f"/persons/{person_id}", v1=True)
1074
+ # Defensive: handle potential {"person": {...}} wrapper
1075
+ person = data.get("person") if isinstance(data, dict) else None
1076
+ source = person if isinstance(person, dict) else data
1077
+ opp_ids = None
1078
+ if isinstance(source, dict):
1079
+ opp_ids = source.get("opportunity_ids") or source.get("opportunityIds")
1080
+
1081
+ if not isinstance(opp_ids, list):
1082
+ return []
1083
+
1084
+ ids = [OpportunityId(int(oid)) for oid in opp_ids if oid is not None]
1085
+ if max_results is not None and max_results >= 0:
1086
+ return ids[:max_results]
1087
+ return ids
1088
+
1089
+ # =========================================================================
1090
+ # Search (V1 API)
1091
+ # =========================================================================
1092
+
1093
+ async def search(
1094
+ self,
1095
+ term: str,
1096
+ *,
1097
+ with_interaction_dates: bool = False,
1098
+ with_interaction_persons: bool = False,
1099
+ with_opportunities: bool = False,
1100
+ page_size: int | None = None,
1101
+ page_token: str | None = None,
1102
+ ) -> V1PaginatedResponse[Person]:
1103
+ """
1104
+ Search for persons by name or email.
1105
+
1106
+ Uses V1 API for search functionality.
1107
+ """
1108
+ params: dict[str, Any] = {"term": term}
1109
+ if with_interaction_dates:
1110
+ params["with_interaction_dates"] = True
1111
+ if with_interaction_persons:
1112
+ params["with_interaction_persons"] = True
1113
+ if with_opportunities:
1114
+ params["with_opportunities"] = True
1115
+ if page_size:
1116
+ params["page_size"] = page_size
1117
+ if page_token:
1118
+ params["page_token"] = page_token
1119
+
1120
+ data = await self._client.get("/persons", params=params, v1=True)
1121
+ items = [Person.model_validate(p) for p in data.get("persons", [])]
1122
+ return V1PaginatedResponse[Person](
1123
+ data=items,
1124
+ next_page_token=data.get("next_page_token"),
1125
+ )
1126
+
1127
+ async def search_pages(
1128
+ self,
1129
+ term: str,
1130
+ *,
1131
+ with_interaction_dates: bool = False,
1132
+ with_interaction_persons: bool = False,
1133
+ with_opportunities: bool = False,
1134
+ page_size: int | None = None,
1135
+ page_token: str | None = None,
1136
+ ) -> AsyncIterator[V1PaginatedResponse[Person]]:
1137
+ """
1138
+ Iterate V1 person-search result pages.
1139
+
1140
+ Useful for scripts that need checkpoint/resume via `next_page_token`.
1141
+
1142
+ Args:
1143
+ term: Search term (name or email)
1144
+ with_interaction_dates: Include interaction date data
1145
+ with_interaction_persons: Include persons for interactions
1146
+ with_opportunities: Include associated opportunity IDs
1147
+ page_size: Results per page (max 500)
1148
+ page_token: Resume from this pagination token
1149
+
1150
+ Yields:
1151
+ V1PaginatedResponse[Person] for each page
1152
+ """
1153
+ requested_token = page_token
1154
+ page = await self.search(
1155
+ term,
1156
+ with_interaction_dates=with_interaction_dates,
1157
+ with_interaction_persons=with_interaction_persons,
1158
+ with_opportunities=with_opportunities,
1159
+ page_size=page_size,
1160
+ page_token=page_token,
1161
+ )
1162
+ while True:
1163
+ yield page
1164
+ next_token = page.next_page_token
1165
+ if not next_token or next_token == requested_token:
1166
+ return
1167
+ requested_token = next_token
1168
+ page = await self.search(
1169
+ term,
1170
+ with_interaction_dates=with_interaction_dates,
1171
+ with_interaction_persons=with_interaction_persons,
1172
+ with_opportunities=with_opportunities,
1173
+ page_size=page_size,
1174
+ page_token=next_token,
1175
+ )
1176
+
1177
+ async def search_all(
1178
+ self,
1179
+ term: str,
1180
+ *,
1181
+ with_interaction_dates: bool = False,
1182
+ with_interaction_persons: bool = False,
1183
+ with_opportunities: bool = False,
1184
+ page_size: int | None = None,
1185
+ page_token: str | None = None,
1186
+ ) -> AsyncIterator[Person]:
1187
+ """
1188
+ Iterate all V1 person-search results with automatic pagination.
1189
+
1190
+ Args:
1191
+ term: Search term (name or email)
1192
+ with_interaction_dates: Include interaction date data
1193
+ with_interaction_persons: Include persons for interactions
1194
+ with_opportunities: Include associated opportunity IDs
1195
+ page_size: Results per page (max 500)
1196
+ page_token: Resume from this pagination token
1197
+
1198
+ Yields:
1199
+ Person objects matching the search term
1200
+ """
1201
+ async for page in self.search_pages(
1202
+ term,
1203
+ with_interaction_dates=with_interaction_dates,
1204
+ with_interaction_persons=with_interaction_persons,
1205
+ with_opportunities=with_opportunities,
1206
+ page_size=page_size,
1207
+ page_token=page_token,
1208
+ ):
1209
+ for person in page.data:
1210
+ yield person
1211
+
1212
+ async def resolve(
1213
+ self,
1214
+ *,
1215
+ email: str | None = None,
1216
+ name: str | None = None,
1217
+ ) -> Person | None:
1218
+ """
1219
+ Find a single person by email or name.
1220
+
1221
+ This is a convenience helper that searches and returns the first exact match,
1222
+ or None if not found. Uses V1 search internally.
1223
+ """
1224
+ if not email and not name:
1225
+ raise ValueError("Must provide either email or name")
1226
+
1227
+ term = email or name or ""
1228
+ async for page in self.search_pages(term, page_size=10):
1229
+ for person in page.data:
1230
+ if _person_matches(person, email=email, name=name):
1231
+ return person
1232
+
1233
+ return None
1234
+
1235
+ async def resolve_all(
1236
+ self,
1237
+ *,
1238
+ email: str | None = None,
1239
+ name: str | None = None,
1240
+ ) -> builtins.list[Person]:
1241
+ """
1242
+ Find all persons matching an email or name.
1243
+
1244
+ Notes:
1245
+ - This auto-paginates V1 search results to collect exact matches.
1246
+ - Unlike resolve(), this returns every match in server-provided order.
1247
+ """
1248
+ if not email and not name:
1249
+ raise ValueError("Must provide either email or name")
1250
+
1251
+ term = email or name or ""
1252
+ matches: builtins.list[Person] = []
1253
+ async for page in self.search_pages(term, page_size=10):
1254
+ for person in page.data:
1255
+ if _person_matches(person, email=email, name=name):
1256
+ matches.append(person)
1257
+ return matches
1258
+
1259
+ # =========================================================================
1260
+ # Write Operations (V1 API)
1261
+ # =========================================================================
1262
+
1263
+ async def create(self, data: PersonCreate) -> Person:
1264
+ """
1265
+ Create a new person.
1266
+
1267
+ Raises:
1268
+ ValidationError: If email conflicts with existing person
1269
+ """
1270
+ payload = data.model_dump(by_alias=True, mode="json")
1271
+ if not data.company_ids:
1272
+ payload.pop("organization_ids", None)
1273
+
1274
+ result = await self._client.post("/persons", json=payload, v1=True)
1275
+
1276
+ if self._client.cache:
1277
+ self._client.cache.invalidate_prefix("person")
1278
+
1279
+ return Person.model_validate(result)
1280
+
1281
+ async def update(
1282
+ self,
1283
+ person_id: PersonId,
1284
+ data: PersonUpdate,
1285
+ ) -> Person:
1286
+ """
1287
+ Update an existing person.
1288
+
1289
+ Note: To add emails/organizations, include existing values plus new ones.
1290
+ """
1291
+ payload = data.model_dump(
1292
+ by_alias=True,
1293
+ mode="json",
1294
+ exclude_unset=True,
1295
+ exclude_none=True,
1296
+ )
1297
+
1298
+ result = await self._client.put(
1299
+ f"/persons/{person_id}",
1300
+ json=payload,
1301
+ v1=True,
1302
+ )
1303
+
1304
+ if self._client.cache:
1305
+ self._client.cache.invalidate_prefix("person")
1306
+
1307
+ return Person.model_validate(result)
1308
+
1309
+ async def delete(self, person_id: PersonId) -> bool:
1310
+ """Delete a person (also deletes associated field values)."""
1311
+ result = await self._client.delete(f"/persons/{person_id}", v1=True)
1312
+
1313
+ if self._client.cache:
1314
+ self._client.cache.invalidate_prefix("person")
1315
+
1316
+ return bool(result.get("success", False))
1317
+
1318
+ # =========================================================================
1319
+ # Merge Operations (V2 BETA)
1320
+ # =========================================================================
1321
+
1322
+ async def merge(
1323
+ self,
1324
+ primary_id: PersonId,
1325
+ duplicate_id: PersonId,
1326
+ ) -> str:
1327
+ """
1328
+ Merge a duplicate person into a primary person.
1329
+
1330
+ Returns a task URL to check merge status.
1331
+ """
1332
+ if not self._client.enable_beta_endpoints:
1333
+ raise BetaEndpointDisabledError(
1334
+ "Person merge is a beta endpoint; set enable_beta_endpoints=True to use it."
1335
+ )
1336
+ result = await self._client.post(
1337
+ "/person-merges",
1338
+ json={
1339
+ "primaryPersonId": int(primary_id),
1340
+ "duplicatePersonId": int(duplicate_id),
1341
+ },
1342
+ )
1343
+ return str(result.get("taskUrl", ""))
1344
+
1345
+ async def get_merge_status(self, task_id: str) -> MergeTask:
1346
+ """Check the status of a merge operation."""
1347
+ data = await self._client.get(f"/tasks/person-merges/{task_id}")
1348
+ return MergeTask.model_validate(data)