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