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