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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- 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)
|