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
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()