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
affinity/client.py
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Affinity API client.
|
|
3
|
+
|
|
4
|
+
Provides a unified interface to all Affinity API functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import importlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import os
|
|
14
|
+
import warnings
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .clients.http import (
|
|
20
|
+
AsyncHTTPClient,
|
|
21
|
+
ClientConfig,
|
|
22
|
+
HTTPClient,
|
|
23
|
+
)
|
|
24
|
+
from .hooks import AnyEventHook, ErrorHook, RequestHook, ResponseHook
|
|
25
|
+
from .models.secondary import WhoAmI
|
|
26
|
+
from .models.types import V1_BASE_URL, V2_BASE_URL
|
|
27
|
+
from .policies import Policies
|
|
28
|
+
from .services.companies import AsyncCompanyService, CompanyService
|
|
29
|
+
from .services.lists import AsyncListService, ListService
|
|
30
|
+
from .services.opportunities import AsyncOpportunityService, OpportunityService
|
|
31
|
+
from .services.persons import AsyncPersonService, PersonService
|
|
32
|
+
from .services.rate_limits import AsyncRateLimitService, RateLimitService
|
|
33
|
+
from .services.tasks import AsyncTaskService, TaskService
|
|
34
|
+
from .services.v1_only import (
|
|
35
|
+
AsyncAuthService,
|
|
36
|
+
AsyncEntityFileService,
|
|
37
|
+
AsyncFieldService,
|
|
38
|
+
AsyncFieldValueChangesService,
|
|
39
|
+
AsyncFieldValueService,
|
|
40
|
+
AsyncInteractionService,
|
|
41
|
+
AsyncNoteService,
|
|
42
|
+
AsyncRelationshipStrengthService,
|
|
43
|
+
AsyncReminderService,
|
|
44
|
+
AsyncWebhookService,
|
|
45
|
+
AuthService,
|
|
46
|
+
EntityFileService,
|
|
47
|
+
FieldService,
|
|
48
|
+
FieldValueChangesService,
|
|
49
|
+
FieldValueService,
|
|
50
|
+
InteractionService,
|
|
51
|
+
NoteService,
|
|
52
|
+
RelationshipStrengthService,
|
|
53
|
+
ReminderService,
|
|
54
|
+
WebhookService,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_DEFAULT_API_KEY_ENV_VAR = "AFFINITY_API_KEY"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _maybe_load_dotenv(
|
|
61
|
+
*,
|
|
62
|
+
load_dotenv: bool,
|
|
63
|
+
dotenv_path: str | os.PathLike[str] | None,
|
|
64
|
+
override: bool,
|
|
65
|
+
) -> None:
|
|
66
|
+
if not load_dotenv:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if importlib.util.find_spec("dotenv") is None:
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"Optional .env support requires python-dotenv; install `affinity-sdk[dotenv]` "
|
|
72
|
+
"or `python-dotenv`."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
dotenv_module = cast(Any, importlib.import_module("dotenv"))
|
|
76
|
+
dotenv_module.load_dotenv(dotenv_path=dotenv_path, override=override)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def maybe_load_dotenv(
|
|
80
|
+
*,
|
|
81
|
+
load_dotenv: bool,
|
|
82
|
+
dotenv_path: str | os.PathLike[str] | None = None,
|
|
83
|
+
override: bool = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Optionally load a `.env` file.
|
|
87
|
+
|
|
88
|
+
This is a public wrapper for the SDK's internal dotenv loader. It is used by
|
|
89
|
+
`Affinity.from_env(...)` and can be reused by integrations (like the CLI)
|
|
90
|
+
that want consistent behavior and error messaging.
|
|
91
|
+
"""
|
|
92
|
+
_maybe_load_dotenv(load_dotenv=load_dotenv, dotenv_path=dotenv_path, override=override)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _api_key_from_env(
|
|
96
|
+
*,
|
|
97
|
+
env_var: str,
|
|
98
|
+
load_dotenv: bool,
|
|
99
|
+
dotenv_path: str | os.PathLike[str] | None,
|
|
100
|
+
dotenv_override: bool,
|
|
101
|
+
) -> str:
|
|
102
|
+
_maybe_load_dotenv(load_dotenv=load_dotenv, dotenv_path=dotenv_path, override=dotenv_override)
|
|
103
|
+
api_key = os.getenv(env_var, "").strip()
|
|
104
|
+
if not api_key:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Missing API key: set `{env_var}` or initialize the client with `api_key=...`."
|
|
107
|
+
)
|
|
108
|
+
return api_key
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Affinity:
|
|
112
|
+
"""
|
|
113
|
+
Synchronous Affinity API client.
|
|
114
|
+
|
|
115
|
+
Provides access to all Affinity API functionality with a clean,
|
|
116
|
+
Pythonic interface. Uses V2 API where available, falls back to V1
|
|
117
|
+
for operations not yet supported in V2.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
```python
|
|
121
|
+
from affinity import Affinity
|
|
122
|
+
|
|
123
|
+
# Initialize with API key
|
|
124
|
+
client = Affinity(api_key="your-api-key")
|
|
125
|
+
|
|
126
|
+
# Use as context manager for automatic cleanup
|
|
127
|
+
with Affinity(api_key="your-api-key") as client:
|
|
128
|
+
# Get all companies
|
|
129
|
+
for company in client.companies.all():
|
|
130
|
+
print(company.name)
|
|
131
|
+
|
|
132
|
+
# Get a specific person with field data
|
|
133
|
+
person = client.persons.get(
|
|
134
|
+
PersonId(12345),
|
|
135
|
+
field_types=["enriched", "global"]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Add a company to a list
|
|
139
|
+
entries = client.lists.entries(ListId(789))
|
|
140
|
+
entry = entries.add_company(CompanyId(456))
|
|
141
|
+
|
|
142
|
+
# Update field values
|
|
143
|
+
entries.update_field_value(
|
|
144
|
+
entry.id,
|
|
145
|
+
FieldId(101),
|
|
146
|
+
"New value"
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Attributes:
|
|
151
|
+
companies: Company (organization) operations
|
|
152
|
+
persons: Person (contact) operations
|
|
153
|
+
lists: List operations
|
|
154
|
+
notes: Note operations
|
|
155
|
+
reminders: Reminder operations
|
|
156
|
+
webhooks: Webhook subscription operations
|
|
157
|
+
interactions: Interaction (email, meeting, etc.) operations
|
|
158
|
+
fields: Custom field operations
|
|
159
|
+
field_values: Field value operations
|
|
160
|
+
field_value_changes: Field value change history operations
|
|
161
|
+
files: Entity file operations
|
|
162
|
+
relationships: Relationship strength queries
|
|
163
|
+
auth: Authentication and rate limit info
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
api_key: str,
|
|
169
|
+
*,
|
|
170
|
+
v1_base_url: str = V1_BASE_URL,
|
|
171
|
+
v2_base_url: str = V2_BASE_URL,
|
|
172
|
+
v1_auth_mode: Literal["bearer", "basic"] = "bearer",
|
|
173
|
+
transport: httpx.BaseTransport | None = None,
|
|
174
|
+
async_transport: httpx.AsyncBaseTransport | None = None,
|
|
175
|
+
enable_beta_endpoints: bool = False,
|
|
176
|
+
allow_insecure_download_redirects: bool = False,
|
|
177
|
+
expected_v2_version: str | None = None,
|
|
178
|
+
timeout: float = 30.0,
|
|
179
|
+
max_retries: int = 3,
|
|
180
|
+
enable_cache: bool = False,
|
|
181
|
+
cache_ttl: float = 300.0,
|
|
182
|
+
log_requests: bool = False,
|
|
183
|
+
on_request: RequestHook | None = None,
|
|
184
|
+
on_response: ResponseHook | None = None,
|
|
185
|
+
on_error: ErrorHook | None = None,
|
|
186
|
+
on_event: AnyEventHook | None = None,
|
|
187
|
+
hook_error_policy: Literal["swallow", "raise"] = "swallow",
|
|
188
|
+
policies: Policies | None = None,
|
|
189
|
+
):
|
|
190
|
+
"""
|
|
191
|
+
Initialize the Affinity client.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
api_key: Your Affinity API key
|
|
195
|
+
v1_base_url: V1 API base URL (default: https://api.affinity.co)
|
|
196
|
+
v2_base_url: V2 API base URL (default: https://api.affinity.co/v2)
|
|
197
|
+
v1_auth_mode: Auth mode for V1 API ("bearer" or "basic")
|
|
198
|
+
transport: Optional `httpx` transport (advanced; useful for mocking in tests)
|
|
199
|
+
async_transport: Optional async `httpx` transport (advanced; useful for mocking in
|
|
200
|
+
tests)
|
|
201
|
+
enable_beta_endpoints: Enable beta V2 endpoints
|
|
202
|
+
allow_insecure_download_redirects: Allow `http://` redirects for file downloads.
|
|
203
|
+
Not recommended; prefer HTTPS-only downloads.
|
|
204
|
+
expected_v2_version: Expected V2 API version for diagnostics (e.g.,
|
|
205
|
+
"2024-01-01"). Used to detect version compatibility issues.
|
|
206
|
+
See TR-015.
|
|
207
|
+
timeout: Request timeout in seconds
|
|
208
|
+
max_retries: Maximum retries for rate-limited requests
|
|
209
|
+
enable_cache: Enable response caching for field metadata
|
|
210
|
+
cache_ttl: Cache TTL in seconds
|
|
211
|
+
log_requests: Log all HTTP requests (for debugging)
|
|
212
|
+
on_request: Hook called before each request (DX-008)
|
|
213
|
+
on_response: Hook called after each response (DX-008)
|
|
214
|
+
on_error: Hook called when a request raises (DX-008)
|
|
215
|
+
on_event: Event hook called for request/response lifecycle events (DX-008)
|
|
216
|
+
hook_error_policy: What to do if hooks raise ("swallow" or "raise")
|
|
217
|
+
policies: Client policies (e.g., disable writes)
|
|
218
|
+
"""
|
|
219
|
+
config = ClientConfig(
|
|
220
|
+
api_key=api_key,
|
|
221
|
+
v1_base_url=v1_base_url,
|
|
222
|
+
v2_base_url=v2_base_url,
|
|
223
|
+
v1_auth_mode=v1_auth_mode,
|
|
224
|
+
transport=transport,
|
|
225
|
+
async_transport=async_transport,
|
|
226
|
+
enable_beta_endpoints=enable_beta_endpoints,
|
|
227
|
+
allow_insecure_download_redirects=allow_insecure_download_redirects,
|
|
228
|
+
expected_v2_version=expected_v2_version,
|
|
229
|
+
timeout=timeout,
|
|
230
|
+
max_retries=max_retries,
|
|
231
|
+
enable_cache=enable_cache,
|
|
232
|
+
cache_ttl=cache_ttl,
|
|
233
|
+
log_requests=log_requests,
|
|
234
|
+
on_request=on_request,
|
|
235
|
+
on_response=on_response,
|
|
236
|
+
on_error=on_error,
|
|
237
|
+
on_event=on_event,
|
|
238
|
+
hook_error_policy=hook_error_policy,
|
|
239
|
+
policies=policies or Policies(),
|
|
240
|
+
)
|
|
241
|
+
self._http = HTTPClient(config)
|
|
242
|
+
|
|
243
|
+
# Resource management tracking
|
|
244
|
+
self._closed = False
|
|
245
|
+
self._entered_context = False
|
|
246
|
+
|
|
247
|
+
# Initialize services
|
|
248
|
+
self._companies: CompanyService | None = None
|
|
249
|
+
self._persons: PersonService | None = None
|
|
250
|
+
self._lists: ListService | None = None
|
|
251
|
+
self._opportunities: OpportunityService | None = None
|
|
252
|
+
self._tasks: TaskService | None = None
|
|
253
|
+
self._notes: NoteService | None = None
|
|
254
|
+
self._reminders: ReminderService | None = None
|
|
255
|
+
self._webhooks: WebhookService | None = None
|
|
256
|
+
self._interactions: InteractionService | None = None
|
|
257
|
+
self._fields: FieldService | None = None
|
|
258
|
+
self._field_values: FieldValueService | None = None
|
|
259
|
+
self._field_value_changes: FieldValueChangesService | None = None
|
|
260
|
+
self._files: EntityFileService | None = None
|
|
261
|
+
self._relationships: RelationshipStrengthService | None = None
|
|
262
|
+
self._auth: AuthService | None = None
|
|
263
|
+
self._rate_limits: RateLimitService | None = None
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def from_env(
|
|
267
|
+
cls,
|
|
268
|
+
*,
|
|
269
|
+
env_var: str = _DEFAULT_API_KEY_ENV_VAR,
|
|
270
|
+
load_dotenv: bool = False,
|
|
271
|
+
dotenv_path: str | os.PathLike[str] | None = None,
|
|
272
|
+
dotenv_override: bool = False,
|
|
273
|
+
transport: httpx.BaseTransport | None = None,
|
|
274
|
+
async_transport: httpx.AsyncBaseTransport | None = None,
|
|
275
|
+
policies: Policies | None = None,
|
|
276
|
+
on_event: AnyEventHook | None = None,
|
|
277
|
+
hook_error_policy: Literal["swallow", "raise"] = "swallow",
|
|
278
|
+
**kwargs: Any,
|
|
279
|
+
) -> Affinity:
|
|
280
|
+
"""
|
|
281
|
+
Create a client using an API key from the environment.
|
|
282
|
+
|
|
283
|
+
By default, reads `AFFINITY_API_KEY`. For local development, you can optionally
|
|
284
|
+
load a `.env` file (requires `python-dotenv`).
|
|
285
|
+
"""
|
|
286
|
+
api_key = _api_key_from_env(
|
|
287
|
+
env_var=env_var,
|
|
288
|
+
load_dotenv=load_dotenv,
|
|
289
|
+
dotenv_path=dotenv_path,
|
|
290
|
+
dotenv_override=dotenv_override,
|
|
291
|
+
)
|
|
292
|
+
return cls(
|
|
293
|
+
api_key=api_key,
|
|
294
|
+
transport=transport,
|
|
295
|
+
async_transport=async_transport,
|
|
296
|
+
policies=policies,
|
|
297
|
+
on_event=on_event,
|
|
298
|
+
hook_error_policy=hook_error_policy,
|
|
299
|
+
**kwargs,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def __enter__(self) -> Affinity:
|
|
303
|
+
self._entered_context = True
|
|
304
|
+
return self
|
|
305
|
+
|
|
306
|
+
def __exit__(self, *args: Any) -> None:
|
|
307
|
+
self.close()
|
|
308
|
+
|
|
309
|
+
def close(self) -> None:
|
|
310
|
+
"""Close the HTTP client and release resources."""
|
|
311
|
+
if not self._closed:
|
|
312
|
+
self._http.close()
|
|
313
|
+
self._closed = True
|
|
314
|
+
|
|
315
|
+
def __del__(self) -> None:
|
|
316
|
+
"""Warn if client was not properly closed."""
|
|
317
|
+
# Use getattr to handle case where __init__ failed before setting _closed
|
|
318
|
+
if not getattr(self, "_closed", True) and not getattr(self, "_entered_context", True):
|
|
319
|
+
warnings.warn(
|
|
320
|
+
"Affinity client was not closed. "
|
|
321
|
+
"Use 'with Affinity.from_env() as client:' "
|
|
322
|
+
"or call client.close() when done.",
|
|
323
|
+
ResourceWarning,
|
|
324
|
+
stacklevel=2,
|
|
325
|
+
)
|
|
326
|
+
# Still close to prevent actual resource leaks
|
|
327
|
+
with contextlib.suppress(Exception):
|
|
328
|
+
self.close()
|
|
329
|
+
|
|
330
|
+
# =========================================================================
|
|
331
|
+
# Service Properties (lazy initialization)
|
|
332
|
+
# =========================================================================
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def companies(self) -> CompanyService:
|
|
336
|
+
"""Company (organization) operations."""
|
|
337
|
+
if self._companies is None:
|
|
338
|
+
self._companies = CompanyService(self._http)
|
|
339
|
+
return self._companies
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def persons(self) -> PersonService:
|
|
343
|
+
"""Person (contact) operations."""
|
|
344
|
+
if self._persons is None:
|
|
345
|
+
self._persons = PersonService(self._http)
|
|
346
|
+
return self._persons
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def lists(self) -> ListService:
|
|
350
|
+
"""List operations."""
|
|
351
|
+
if self._lists is None:
|
|
352
|
+
self._lists = ListService(self._http)
|
|
353
|
+
return self._lists
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def opportunities(self) -> OpportunityService:
|
|
357
|
+
"""Opportunity operations."""
|
|
358
|
+
if self._opportunities is None:
|
|
359
|
+
self._opportunities = OpportunityService(self._http)
|
|
360
|
+
return self._opportunities
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def tasks(self) -> TaskService:
|
|
364
|
+
"""Long-running task operations (polling, waiting)."""
|
|
365
|
+
if self._tasks is None:
|
|
366
|
+
self._tasks = TaskService(self._http)
|
|
367
|
+
return self._tasks
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def notes(self) -> NoteService:
|
|
371
|
+
"""Note operations."""
|
|
372
|
+
if self._notes is None:
|
|
373
|
+
self._notes = NoteService(self._http)
|
|
374
|
+
return self._notes
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def reminders(self) -> ReminderService:
|
|
378
|
+
"""Reminder operations."""
|
|
379
|
+
if self._reminders is None:
|
|
380
|
+
self._reminders = ReminderService(self._http)
|
|
381
|
+
return self._reminders
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def webhooks(self) -> WebhookService:
|
|
385
|
+
"""Webhook subscription operations."""
|
|
386
|
+
if self._webhooks is None:
|
|
387
|
+
self._webhooks = WebhookService(self._http)
|
|
388
|
+
return self._webhooks
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def interactions(self) -> InteractionService:
|
|
392
|
+
"""Interaction operations."""
|
|
393
|
+
if self._interactions is None:
|
|
394
|
+
self._interactions = InteractionService(self._http)
|
|
395
|
+
return self._interactions
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def fields(self) -> FieldService:
|
|
399
|
+
"""Custom field operations."""
|
|
400
|
+
if self._fields is None:
|
|
401
|
+
self._fields = FieldService(self._http)
|
|
402
|
+
return self._fields
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def field_values(self) -> FieldValueService:
|
|
406
|
+
"""Field value operations."""
|
|
407
|
+
if self._field_values is None:
|
|
408
|
+
self._field_values = FieldValueService(self._http)
|
|
409
|
+
return self._field_values
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def field_value_changes(self) -> FieldValueChangesService:
|
|
413
|
+
"""Field value change history queries."""
|
|
414
|
+
if self._field_value_changes is None:
|
|
415
|
+
self._field_value_changes = FieldValueChangesService(self._http)
|
|
416
|
+
return self._field_value_changes
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def files(self) -> EntityFileService:
|
|
420
|
+
"""Entity file operations."""
|
|
421
|
+
if self._files is None:
|
|
422
|
+
self._files = EntityFileService(self._http)
|
|
423
|
+
return self._files
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def relationships(self) -> RelationshipStrengthService:
|
|
427
|
+
"""Relationship strength queries."""
|
|
428
|
+
if self._relationships is None:
|
|
429
|
+
self._relationships = RelationshipStrengthService(self._http)
|
|
430
|
+
return self._relationships
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def auth(self) -> AuthService:
|
|
434
|
+
"""Authentication info."""
|
|
435
|
+
if self._auth is None:
|
|
436
|
+
self._auth = AuthService(self._http)
|
|
437
|
+
return self._auth
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def rate_limits(self) -> RateLimitService:
|
|
441
|
+
"""Unified rate limit information (version-agnostic)."""
|
|
442
|
+
if self._rate_limits is None:
|
|
443
|
+
self._rate_limits = RateLimitService(self._http)
|
|
444
|
+
return self._rate_limits
|
|
445
|
+
|
|
446
|
+
# =========================================================================
|
|
447
|
+
# Utility Methods
|
|
448
|
+
# =========================================================================
|
|
449
|
+
|
|
450
|
+
def clear_cache(self) -> None:
|
|
451
|
+
"""Clear the response cache."""
|
|
452
|
+
if self._http.cache:
|
|
453
|
+
self._http.cache.clear()
|
|
454
|
+
|
|
455
|
+
def whoami(self) -> WhoAmI:
|
|
456
|
+
"""Convenience wrapper for `client.auth.whoami()`."""
|
|
457
|
+
return self.auth.whoami()
|
|
458
|
+
|
|
459
|
+
# Note: dict-style `rate_limit_state` is intentionally not part of the public API.
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# =============================================================================
|
|
463
|
+
# Async Client (same interface, async methods)
|
|
464
|
+
# =============================================================================
|
|
465
|
+
|
|
466
|
+
# The async client mirrors the sync client interface for core services (TR-009).
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class AsyncAffinity:
|
|
470
|
+
"""
|
|
471
|
+
Asynchronous Affinity API client.
|
|
472
|
+
|
|
473
|
+
Same interface as Affinity but with async/await support.
|
|
474
|
+
|
|
475
|
+
Example:
|
|
476
|
+
```python
|
|
477
|
+
async with AsyncAffinity(api_key="your-key") as client:
|
|
478
|
+
async for company in client.companies.all():
|
|
479
|
+
print(company.name)
|
|
480
|
+
```
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
def __init__(
|
|
484
|
+
self,
|
|
485
|
+
api_key: str,
|
|
486
|
+
*,
|
|
487
|
+
v1_base_url: str = V1_BASE_URL,
|
|
488
|
+
v2_base_url: str = V2_BASE_URL,
|
|
489
|
+
v1_auth_mode: Literal["bearer", "basic"] = "bearer",
|
|
490
|
+
transport: httpx.BaseTransport | None = None,
|
|
491
|
+
async_transport: httpx.AsyncBaseTransport | None = None,
|
|
492
|
+
enable_beta_endpoints: bool = False,
|
|
493
|
+
allow_insecure_download_redirects: bool = False,
|
|
494
|
+
expected_v2_version: str | None = None,
|
|
495
|
+
timeout: float = 30.0,
|
|
496
|
+
max_retries: int = 3,
|
|
497
|
+
enable_cache: bool = False,
|
|
498
|
+
cache_ttl: float = 300.0,
|
|
499
|
+
log_requests: bool = False,
|
|
500
|
+
on_request: RequestHook | None = None,
|
|
501
|
+
on_response: ResponseHook | None = None,
|
|
502
|
+
on_error: ErrorHook | None = None,
|
|
503
|
+
on_event: AnyEventHook | None = None,
|
|
504
|
+
hook_error_policy: Literal["swallow", "raise"] = "swallow",
|
|
505
|
+
policies: Policies | None = None,
|
|
506
|
+
):
|
|
507
|
+
"""
|
|
508
|
+
Initialize the async Affinity client.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
api_key: Your Affinity API key
|
|
512
|
+
v1_base_url: V1 API base URL (default: https://api.affinity.co)
|
|
513
|
+
v2_base_url: V2 API base URL (default: https://api.affinity.co/v2)
|
|
514
|
+
v1_auth_mode: Auth mode for V1 API ("bearer" or "basic")
|
|
515
|
+
transport: Optional `httpx` transport (advanced; useful for mocking in tests)
|
|
516
|
+
async_transport: Optional async `httpx` transport (advanced; useful for mocking in
|
|
517
|
+
tests)
|
|
518
|
+
enable_beta_endpoints: Enable beta V2 endpoints
|
|
519
|
+
allow_insecure_download_redirects: Allow `http://` redirects for file downloads.
|
|
520
|
+
Not recommended; prefer HTTPS-only downloads.
|
|
521
|
+
expected_v2_version: Expected V2 API version for diagnostics (e.g.,
|
|
522
|
+
"2024-01-01"). Used to detect version compatibility issues.
|
|
523
|
+
See TR-015.
|
|
524
|
+
timeout: Request timeout in seconds
|
|
525
|
+
max_retries: Maximum retries for rate-limited requests
|
|
526
|
+
enable_cache: Enable response caching for field metadata
|
|
527
|
+
cache_ttl: Cache TTL in seconds
|
|
528
|
+
log_requests: Log all HTTP requests (for debugging)
|
|
529
|
+
on_request: Hook called before each request (DX-008)
|
|
530
|
+
on_response: Hook called after each response (DX-008)
|
|
531
|
+
on_error: Hook called when a request raises (DX-008)
|
|
532
|
+
on_event: Event hook called for request/response lifecycle events (DX-008)
|
|
533
|
+
hook_error_policy: What to do if hooks raise ("swallow" or "raise")
|
|
534
|
+
"""
|
|
535
|
+
config = ClientConfig(
|
|
536
|
+
api_key=api_key,
|
|
537
|
+
v1_base_url=v1_base_url,
|
|
538
|
+
v2_base_url=v2_base_url,
|
|
539
|
+
v1_auth_mode=v1_auth_mode,
|
|
540
|
+
transport=transport,
|
|
541
|
+
async_transport=async_transport,
|
|
542
|
+
enable_beta_endpoints=enable_beta_endpoints,
|
|
543
|
+
allow_insecure_download_redirects=allow_insecure_download_redirects,
|
|
544
|
+
expected_v2_version=expected_v2_version,
|
|
545
|
+
timeout=timeout,
|
|
546
|
+
max_retries=max_retries,
|
|
547
|
+
enable_cache=enable_cache,
|
|
548
|
+
cache_ttl=cache_ttl,
|
|
549
|
+
log_requests=log_requests,
|
|
550
|
+
on_request=on_request,
|
|
551
|
+
on_response=on_response,
|
|
552
|
+
on_error=on_error,
|
|
553
|
+
on_event=on_event,
|
|
554
|
+
hook_error_policy=hook_error_policy,
|
|
555
|
+
policies=policies or Policies(),
|
|
556
|
+
)
|
|
557
|
+
self._http = AsyncHTTPClient(config)
|
|
558
|
+
|
|
559
|
+
# Resource management tracking
|
|
560
|
+
self._closed = False
|
|
561
|
+
self._entered_context = False
|
|
562
|
+
|
|
563
|
+
self._companies: AsyncCompanyService | None = None
|
|
564
|
+
self._persons: AsyncPersonService | None = None
|
|
565
|
+
self._opportunities: AsyncOpportunityService | None = None
|
|
566
|
+
self._lists: AsyncListService | None = None
|
|
567
|
+
self._tasks: AsyncTaskService | None = None
|
|
568
|
+
self._notes: AsyncNoteService | None = None
|
|
569
|
+
self._reminders: AsyncReminderService | None = None
|
|
570
|
+
self._webhooks: AsyncWebhookService | None = None
|
|
571
|
+
self._interactions: AsyncInteractionService | None = None
|
|
572
|
+
self._fields: AsyncFieldService | None = None
|
|
573
|
+
self._field_values: AsyncFieldValueService | None = None
|
|
574
|
+
self._field_value_changes: AsyncFieldValueChangesService | None = None
|
|
575
|
+
self._files: AsyncEntityFileService | None = None
|
|
576
|
+
self._relationships: AsyncRelationshipStrengthService | None = None
|
|
577
|
+
self._auth: AsyncAuthService | None = None
|
|
578
|
+
self._rate_limits: AsyncRateLimitService | None = None
|
|
579
|
+
|
|
580
|
+
@classmethod
|
|
581
|
+
def from_env(
|
|
582
|
+
cls,
|
|
583
|
+
*,
|
|
584
|
+
env_var: str = _DEFAULT_API_KEY_ENV_VAR,
|
|
585
|
+
load_dotenv: bool = False,
|
|
586
|
+
dotenv_path: str | os.PathLike[str] | None = None,
|
|
587
|
+
dotenv_override: bool = False,
|
|
588
|
+
transport: httpx.BaseTransport | None = None,
|
|
589
|
+
async_transport: httpx.AsyncBaseTransport | None = None,
|
|
590
|
+
policies: Policies | None = None,
|
|
591
|
+
on_event: AnyEventHook | None = None,
|
|
592
|
+
hook_error_policy: Literal["swallow", "raise"] = "swallow",
|
|
593
|
+
**kwargs: Any,
|
|
594
|
+
) -> AsyncAffinity:
|
|
595
|
+
"""
|
|
596
|
+
Create an async client using an API key from the environment.
|
|
597
|
+
|
|
598
|
+
By default, reads `AFFINITY_API_KEY`. For local development, you can optionally
|
|
599
|
+
load a `.env` file (requires `python-dotenv`).
|
|
600
|
+
"""
|
|
601
|
+
api_key = _api_key_from_env(
|
|
602
|
+
env_var=env_var,
|
|
603
|
+
load_dotenv=load_dotenv,
|
|
604
|
+
dotenv_path=dotenv_path,
|
|
605
|
+
dotenv_override=dotenv_override,
|
|
606
|
+
)
|
|
607
|
+
return cls(
|
|
608
|
+
api_key=api_key,
|
|
609
|
+
transport=transport,
|
|
610
|
+
async_transport=async_transport,
|
|
611
|
+
policies=policies,
|
|
612
|
+
on_event=on_event,
|
|
613
|
+
hook_error_policy=hook_error_policy,
|
|
614
|
+
**kwargs,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def companies(self) -> AsyncCompanyService:
|
|
619
|
+
"""Company (organization) operations."""
|
|
620
|
+
if self._companies is None:
|
|
621
|
+
self._companies = AsyncCompanyService(self._http)
|
|
622
|
+
return self._companies
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def persons(self) -> AsyncPersonService:
|
|
626
|
+
"""Person (contact) operations."""
|
|
627
|
+
if self._persons is None:
|
|
628
|
+
self._persons = AsyncPersonService(self._http)
|
|
629
|
+
return self._persons
|
|
630
|
+
|
|
631
|
+
@property
|
|
632
|
+
def opportunities(self) -> AsyncOpportunityService:
|
|
633
|
+
"""Opportunity operations."""
|
|
634
|
+
if self._opportunities is None:
|
|
635
|
+
self._opportunities = AsyncOpportunityService(self._http)
|
|
636
|
+
return self._opportunities
|
|
637
|
+
|
|
638
|
+
@property
|
|
639
|
+
def lists(self) -> AsyncListService:
|
|
640
|
+
"""List and list entry operations."""
|
|
641
|
+
if self._lists is None:
|
|
642
|
+
self._lists = AsyncListService(self._http)
|
|
643
|
+
return self._lists
|
|
644
|
+
|
|
645
|
+
@property
|
|
646
|
+
def tasks(self) -> AsyncTaskService:
|
|
647
|
+
"""Long-running task operations (polling, waiting)."""
|
|
648
|
+
if self._tasks is None:
|
|
649
|
+
self._tasks = AsyncTaskService(self._http)
|
|
650
|
+
return self._tasks
|
|
651
|
+
|
|
652
|
+
@property
|
|
653
|
+
def notes(self) -> AsyncNoteService:
|
|
654
|
+
"""Note operations."""
|
|
655
|
+
if self._notes is None:
|
|
656
|
+
self._notes = AsyncNoteService(self._http)
|
|
657
|
+
return self._notes
|
|
658
|
+
|
|
659
|
+
@property
|
|
660
|
+
def reminders(self) -> AsyncReminderService:
|
|
661
|
+
"""Reminder operations."""
|
|
662
|
+
if self._reminders is None:
|
|
663
|
+
self._reminders = AsyncReminderService(self._http)
|
|
664
|
+
return self._reminders
|
|
665
|
+
|
|
666
|
+
@property
|
|
667
|
+
def webhooks(self) -> AsyncWebhookService:
|
|
668
|
+
"""Webhook subscription operations."""
|
|
669
|
+
if self._webhooks is None:
|
|
670
|
+
self._webhooks = AsyncWebhookService(self._http)
|
|
671
|
+
return self._webhooks
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def interactions(self) -> AsyncInteractionService:
|
|
675
|
+
"""Interaction operations."""
|
|
676
|
+
if self._interactions is None:
|
|
677
|
+
self._interactions = AsyncInteractionService(self._http)
|
|
678
|
+
return self._interactions
|
|
679
|
+
|
|
680
|
+
@property
|
|
681
|
+
def fields(self) -> AsyncFieldService:
|
|
682
|
+
"""Custom field operations."""
|
|
683
|
+
if self._fields is None:
|
|
684
|
+
self._fields = AsyncFieldService(self._http)
|
|
685
|
+
return self._fields
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def field_values(self) -> AsyncFieldValueService:
|
|
689
|
+
"""Field value operations."""
|
|
690
|
+
if self._field_values is None:
|
|
691
|
+
self._field_values = AsyncFieldValueService(self._http)
|
|
692
|
+
return self._field_values
|
|
693
|
+
|
|
694
|
+
@property
|
|
695
|
+
def field_value_changes(self) -> AsyncFieldValueChangesService:
|
|
696
|
+
"""Field value change history queries."""
|
|
697
|
+
if self._field_value_changes is None:
|
|
698
|
+
self._field_value_changes = AsyncFieldValueChangesService(self._http)
|
|
699
|
+
return self._field_value_changes
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def files(self) -> AsyncEntityFileService:
|
|
703
|
+
"""Entity file operations."""
|
|
704
|
+
if self._files is None:
|
|
705
|
+
self._files = AsyncEntityFileService(self._http)
|
|
706
|
+
return self._files
|
|
707
|
+
|
|
708
|
+
@property
|
|
709
|
+
def relationships(self) -> AsyncRelationshipStrengthService:
|
|
710
|
+
"""Relationship strength queries."""
|
|
711
|
+
if self._relationships is None:
|
|
712
|
+
self._relationships = AsyncRelationshipStrengthService(self._http)
|
|
713
|
+
return self._relationships
|
|
714
|
+
|
|
715
|
+
@property
|
|
716
|
+
def auth(self) -> AsyncAuthService:
|
|
717
|
+
"""Authentication info."""
|
|
718
|
+
if self._auth is None:
|
|
719
|
+
self._auth = AsyncAuthService(self._http)
|
|
720
|
+
return self._auth
|
|
721
|
+
|
|
722
|
+
@property
|
|
723
|
+
def rate_limits(self) -> AsyncRateLimitService:
|
|
724
|
+
"""Unified rate limit information (version-agnostic)."""
|
|
725
|
+
if self._rate_limits is None:
|
|
726
|
+
self._rate_limits = AsyncRateLimitService(self._http)
|
|
727
|
+
return self._rate_limits
|
|
728
|
+
|
|
729
|
+
async def __aenter__(self) -> AsyncAffinity:
|
|
730
|
+
"""Enter an async context and return this client."""
|
|
731
|
+
self._entered_context = True
|
|
732
|
+
return self
|
|
733
|
+
|
|
734
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
735
|
+
"""Exit the async context and close the underlying HTTP client."""
|
|
736
|
+
await self.close()
|
|
737
|
+
|
|
738
|
+
async def close(self) -> None:
|
|
739
|
+
"""Close the HTTP client."""
|
|
740
|
+
if not self._closed:
|
|
741
|
+
await self._http.close()
|
|
742
|
+
self._closed = True
|
|
743
|
+
|
|
744
|
+
def __del__(self) -> None:
|
|
745
|
+
"""Warn if client was not properly closed."""
|
|
746
|
+
# Use getattr to handle case where __init__ failed before setting _closed
|
|
747
|
+
if not getattr(self, "_closed", True) and not getattr(self, "_entered_context", True):
|
|
748
|
+
warnings.warn(
|
|
749
|
+
"AsyncAffinity client was not closed. "
|
|
750
|
+
"Use 'async with AsyncAffinity.from_env() as client:' "
|
|
751
|
+
"or call await client.close() when done.",
|
|
752
|
+
ResourceWarning,
|
|
753
|
+
stacklevel=2,
|
|
754
|
+
)
|
|
755
|
+
# Schedule close if loop is running; otherwise silently skip
|
|
756
|
+
# (can't await in __del__, and sync close would block)
|
|
757
|
+
# Use call_soon_threadsafe for thread-safety in multi-threaded loops
|
|
758
|
+
try:
|
|
759
|
+
loop = asyncio.get_running_loop()
|
|
760
|
+
loop.call_soon_threadsafe(lambda: asyncio.create_task(self._http.close()))
|
|
761
|
+
except RuntimeError:
|
|
762
|
+
pass # No running loop - skip async cleanup
|
|
763
|
+
|
|
764
|
+
def clear_cache(self) -> None:
|
|
765
|
+
"""Clear the response cache."""
|
|
766
|
+
if self._http.cache:
|
|
767
|
+
self._http.cache.clear()
|
|
768
|
+
|
|
769
|
+
async def whoami(self) -> WhoAmI:
|
|
770
|
+
"""Convenience wrapper for `client.auth.whoami()`."""
|
|
771
|
+
return await self.auth.whoami()
|