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/exceptions.py ADDED
@@ -0,0 +1,615 @@
1
+ """
2
+ Custom exceptions for the Affinity API client.
3
+
4
+ All exceptions inherit from AffinityError for easy catching of all library errors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ErrorDiagnostics:
15
+ method: str | None = None
16
+ url: str | None = None
17
+ request_params: dict[str, Any] | None = None
18
+ api_version: str | None = None # "v1" | "v2" (string to avoid circular types)
19
+ base_url: str | None = None
20
+ request_id: str | None = None
21
+ http_version: str | None = None
22
+ response_headers: dict[str, str] | None = None
23
+ response_body_snippet: str | None = None
24
+
25
+
26
+ class AffinityError(Exception):
27
+ """Base exception for all Affinity API errors."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ status_code: int | None = None,
34
+ response_body: Any | None = None,
35
+ diagnostics: ErrorDiagnostics | None = None,
36
+ ):
37
+ super().__init__(message)
38
+ self.message = message
39
+ self.status_code = status_code
40
+ self.response_body = response_body
41
+ self.diagnostics = diagnostics
42
+
43
+ def __str__(self) -> str:
44
+ base = self.message
45
+ if self.status_code:
46
+ base = f"[{self.status_code}] {base}"
47
+ if self.diagnostics:
48
+ # Include method + url if both present, or just url if only url present
49
+ if self.diagnostics.method and self.diagnostics.url:
50
+ base = f"{base} ({self.diagnostics.method} {self.diagnostics.url})"
51
+ elif self.diagnostics.url:
52
+ base = f"{base} (url={self.diagnostics.url})"
53
+ if self.diagnostics.request_id:
54
+ base = f"{base} [request_id={self.diagnostics.request_id}]"
55
+ return base
56
+
57
+
58
+ # =============================================================================
59
+ # HTTP Errors
60
+ # =============================================================================
61
+
62
+
63
+ class AuthenticationError(AffinityError):
64
+ """
65
+ 401 Unauthorized - Invalid or missing API key.
66
+
67
+ Your API key is invalid or was not provided.
68
+ """
69
+
70
+ pass
71
+
72
+
73
+ class AuthorizationError(AffinityError):
74
+ """
75
+ 403 Forbidden - Insufficient permissions.
76
+
77
+ You don't have permission to access this resource or perform this action.
78
+ This can happen with:
79
+ - Private lists you don't have access to
80
+ - Admin-only actions
81
+ - Resource-level permission restrictions
82
+ """
83
+
84
+ pass
85
+
86
+
87
+ class NotFoundError(AffinityError):
88
+ """
89
+ 404 Not Found - Resource doesn't exist.
90
+
91
+ The requested resource (person, company, list, etc.) was not found.
92
+ This could mean:
93
+ - The ID is invalid
94
+ - The resource was deleted
95
+ - You don't have access to view it
96
+ """
97
+
98
+ pass
99
+
100
+
101
+ class ValidationError(AffinityError):
102
+ """
103
+ 400/422 Bad Request/Unprocessable Entity - Invalid request data.
104
+
105
+ The request data is malformed or logically invalid.
106
+ Check the error message for details about what's wrong.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ message: str,
112
+ *,
113
+ param: str | None = None,
114
+ status_code: int | None = None,
115
+ response_body: Any | None = None,
116
+ diagnostics: ErrorDiagnostics | None = None,
117
+ ):
118
+ super().__init__(
119
+ message,
120
+ status_code=status_code,
121
+ response_body=response_body,
122
+ diagnostics=diagnostics,
123
+ )
124
+ self.param = param
125
+
126
+ def __str__(self) -> str:
127
+ base = super().__str__()
128
+ if self.param:
129
+ return f"{base} (param: {self.param})"
130
+ return base
131
+
132
+
133
+ class RateLimitError(AffinityError):
134
+ """
135
+ 429 Too Many Requests - Rate limit exceeded.
136
+
137
+ You've exceeded the API rate limit. Wait before retrying.
138
+ Check the response headers for rate limit info:
139
+ - X-Ratelimit-Limit-User-Reset: Seconds until per-minute limit resets
140
+ - X-Ratelimit-Limit-Org-Reset: Seconds until monthly limit resets
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ message: str,
146
+ *,
147
+ retry_after: int | None = None,
148
+ status_code: int | None = None,
149
+ response_body: Any | None = None,
150
+ diagnostics: ErrorDiagnostics | None = None,
151
+ ):
152
+ super().__init__(
153
+ message,
154
+ status_code=status_code,
155
+ response_body=response_body,
156
+ diagnostics=diagnostics,
157
+ )
158
+ self.retry_after = retry_after
159
+
160
+
161
+ class ConflictError(AffinityError):
162
+ """
163
+ 409 Conflict - Resource conflict.
164
+
165
+ The request conflicts with the current state of the resource.
166
+ For example:
167
+ - Trying to create a person with an email that already exists
168
+ - Concurrent modification conflicts
169
+ """
170
+
171
+ pass
172
+
173
+
174
+ class ServerError(AffinityError):
175
+ """
176
+ 500/503 Internal Server Error - Server-side problem.
177
+
178
+ Something went wrong on Affinity's servers.
179
+ Try again later, and contact support if the problem persists.
180
+ """
181
+
182
+ pass
183
+
184
+
185
+ # =============================================================================
186
+ # Client-side Errors
187
+ # =============================================================================
188
+
189
+
190
+ class ConfigurationError(AffinityError):
191
+ """
192
+ Configuration error - missing or invalid client configuration.
193
+
194
+ Check that you've provided:
195
+ - A valid API key
196
+ - Correct base URLs (if customizing)
197
+ """
198
+
199
+ pass
200
+
201
+
202
+ class TimeoutError(AffinityError):
203
+ """
204
+ Request timeout.
205
+
206
+ The request took too long to complete.
207
+ This could be due to:
208
+ - Network issues
209
+ - Large data sets
210
+ - Server overload
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ message: str,
216
+ *,
217
+ diagnostics: ErrorDiagnostics | None = None,
218
+ ):
219
+ super().__init__(message, diagnostics=diagnostics)
220
+
221
+
222
+ class NetworkError(AffinityError):
223
+ """
224
+ Network-level error.
225
+
226
+ Failed to connect to the Affinity API.
227
+ Check your internet connection and firewall settings.
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ message: str,
233
+ *,
234
+ diagnostics: ErrorDiagnostics | None = None,
235
+ ):
236
+ super().__init__(message, diagnostics=diagnostics)
237
+
238
+
239
+ class PolicyError(AffinityError):
240
+ """Raised when a client policy blocks an attempted operation."""
241
+
242
+ pass
243
+
244
+
245
+ class WriteNotAllowedError(PolicyError):
246
+ """Raised when a write operation is attempted while the write policy denies writes."""
247
+
248
+ def __init__(self, message: str, *, method: str, url: str):
249
+ super().__init__(message, diagnostics=ErrorDiagnostics(method=method, url=url))
250
+ self.method = method
251
+ self.url = url
252
+
253
+
254
+ # =============================================================================
255
+ # Pagination Errors
256
+ # =============================================================================
257
+
258
+
259
+ class TooManyResultsError(AffinityError):
260
+ """
261
+ Raised when ``.all()`` exceeds the limit.
262
+
263
+ The default limit is 100,000 items (approximately 100MB for typical Person objects).
264
+ This protects against OOM errors when paginating large datasets.
265
+
266
+ To resolve:
267
+ - Use ``.pages()`` for streaming iteration (memory-efficient)
268
+ - Add filters to reduce result size
269
+ - Pass ``limit=None`` to ``.all()`` if you're certain you need all results
270
+ - Pass a custom ``limit=500_000`` if you need more than the default
271
+ """
272
+
273
+ pass
274
+
275
+
276
+ # =============================================================================
277
+ # URL Safety Errors
278
+ # =============================================================================
279
+
280
+
281
+ class UnsafeUrlError(AffinityError):
282
+ """
283
+ SDK blocked following a server-provided URL.
284
+
285
+ Raised when SafeFollowUrl policy rejects a URL (scheme/host/userinfo/redirect).
286
+ """
287
+
288
+ def __init__(self, message: str, *, url: str | None = None):
289
+ super().__init__(
290
+ message,
291
+ diagnostics=ErrorDiagnostics(url=url) if url else None,
292
+ )
293
+ self.url = url
294
+
295
+
296
+ # =============================================================================
297
+ # Business Logic Errors
298
+ # =============================================================================
299
+
300
+
301
+ class EntityNotFoundError(NotFoundError):
302
+ """
303
+ Specific entity not found.
304
+
305
+ Provides type-safe context about which entity type was not found.
306
+ """
307
+
308
+ def __init__(
309
+ self,
310
+ entity_type: str,
311
+ entity_id: int | str,
312
+ **kwargs: Any,
313
+ ):
314
+ message = f"{entity_type} with ID {entity_id} not found"
315
+ super().__init__(message, **kwargs)
316
+ self.entity_type = entity_type
317
+ self.entity_id = entity_id
318
+
319
+
320
+ class FieldNotFoundError(NotFoundError):
321
+ """Field with the specified ID was not found."""
322
+
323
+ pass
324
+
325
+
326
+ class ListNotFoundError(NotFoundError):
327
+ """List with the specified ID was not found."""
328
+
329
+ pass
330
+
331
+
332
+ class PersonNotFoundError(EntityNotFoundError):
333
+ """Person with the specified ID was not found."""
334
+
335
+ def __init__(self, person_id: int, **kwargs: Any):
336
+ super().__init__("Person", person_id, **kwargs)
337
+
338
+
339
+ class CompanyNotFoundError(EntityNotFoundError):
340
+ """Company with the specified ID was not found."""
341
+
342
+ def __init__(self, company_id: int, **kwargs: Any):
343
+ super().__init__("Company", company_id, **kwargs)
344
+
345
+
346
+ class OpportunityNotFoundError(EntityNotFoundError):
347
+ """Opportunity with the specified ID was not found."""
348
+
349
+ def __init__(self, opportunity_id: int, **kwargs: Any):
350
+ super().__init__("Opportunity", opportunity_id, **kwargs)
351
+
352
+
353
+ # =============================================================================
354
+ # API Version Errors
355
+ # =============================================================================
356
+
357
+
358
+ class UnsupportedOperationError(AffinityError):
359
+ """
360
+ Operation not supported by the current API version.
361
+
362
+ Some operations are only available in V1 or V2.
363
+ """
364
+
365
+ pass
366
+
367
+
368
+ class BetaEndpointDisabledError(UnsupportedOperationError):
369
+ """Attempted to call a beta endpoint without opt-in."""
370
+
371
+ pass
372
+
373
+
374
+ class VersionCompatibilityError(AffinityError):
375
+ """
376
+ Response shape mismatch suggests API version incompatibility.
377
+
378
+ TR-015: Raised when the SDK detects response-shape mismatches that
379
+ appear version-related. This typically means the API key's configured
380
+ v2 Default API Version differs from what the SDK expects.
381
+
382
+ Guidance:
383
+ 1. Check your API key's v2 Default API Version in the Affinity dashboard
384
+ 2. Ensure it matches the expected_v2_version configured in the SDK
385
+ 3. See: https://developer.affinity.co/#section/Getting-Started/Versioning
386
+ """
387
+
388
+ def __init__(
389
+ self,
390
+ message: str,
391
+ *,
392
+ expected_version: str | None = None,
393
+ parsing_error: str | None = None,
394
+ status_code: int | None = None,
395
+ response_body: Any | None = None,
396
+ diagnostics: ErrorDiagnostics | None = None,
397
+ ):
398
+ super().__init__(
399
+ message,
400
+ status_code=status_code,
401
+ response_body=response_body,
402
+ diagnostics=diagnostics,
403
+ )
404
+ self.expected_version = expected_version
405
+ self.parsing_error = parsing_error
406
+
407
+ def __str__(self) -> str:
408
+ base = super().__str__()
409
+ hints = []
410
+ if self.expected_version:
411
+ hints.append(f"expected_v2_version={self.expected_version}")
412
+ if self.parsing_error:
413
+ hints.append(f"parsing_error={self.parsing_error}")
414
+ if hints:
415
+ base = f"{base} ({', '.join(hints)})"
416
+ return base
417
+
418
+
419
+ class DeprecationWarning(AffinityError):
420
+ """
421
+ Feature is deprecated and may be removed.
422
+ """
423
+
424
+ pass
425
+
426
+
427
+ # =============================================================================
428
+ # Error Factory
429
+ # =============================================================================
430
+
431
+
432
+ def error_from_response(
433
+ status_code: int,
434
+ response_body: Any,
435
+ *,
436
+ retry_after: int | None = None,
437
+ diagnostics: ErrorDiagnostics | None = None,
438
+ ) -> AffinityError:
439
+ """
440
+ Create the appropriate exception from an API error response.
441
+
442
+ Args:
443
+ status_code: HTTP status code
444
+ response_body: Parsed response body (usually dict with 'errors')
445
+ retry_after: Retry-After header value for rate limits
446
+
447
+ Returns:
448
+ Appropriate AffinityError subclass
449
+ """
450
+ # Try to extract message from response
451
+ message = "Unknown error"
452
+ param = None
453
+
454
+ extracted = False
455
+ if isinstance(response_body, dict):
456
+ errors = response_body.get("errors")
457
+ if isinstance(errors, list) and errors:
458
+ for item in errors:
459
+ if isinstance(item, dict):
460
+ msg = item.get("message")
461
+ if isinstance(msg, str) and msg.strip():
462
+ message = msg.strip()
463
+ p = item.get("param")
464
+ if isinstance(p, str) and p.strip():
465
+ param = p
466
+ extracted = True
467
+ break
468
+ elif isinstance(item, str) and item.strip():
469
+ message = item.strip()
470
+ extracted = True
471
+ break
472
+
473
+ if not extracted:
474
+ top_message = response_body.get("message")
475
+ if isinstance(top_message, str) and top_message.strip():
476
+ message = top_message.strip()
477
+ extracted = True
478
+ else:
479
+ detail = response_body.get("detail")
480
+ if isinstance(detail, str) and detail.strip():
481
+ message = detail.strip()
482
+ extracted = True
483
+ else:
484
+ error_obj = response_body.get("error")
485
+ if isinstance(error_obj, dict):
486
+ nested_message = error_obj.get("message")
487
+ if isinstance(nested_message, str) and nested_message.strip():
488
+ message = nested_message.strip()
489
+ extracted = True
490
+ elif isinstance(error_obj, str) and error_obj.strip():
491
+ message = error_obj.strip()
492
+ extracted = True
493
+
494
+ if not extracted and isinstance(response_body, list) and response_body:
495
+ first = response_body[0]
496
+ if isinstance(first, dict):
497
+ msg = first.get("message") or first.get("error") or first.get("detail")
498
+ if isinstance(msg, str) and msg.strip():
499
+ message = msg.strip()
500
+ extracted = True
501
+ elif isinstance(first, str) and first.strip():
502
+ message = first.strip()
503
+ extracted = True
504
+
505
+ if (
506
+ message == "Unknown error"
507
+ and diagnostics is not None
508
+ and isinstance(diagnostics.response_body_snippet, str)
509
+ ):
510
+ snippet = diagnostics.response_body_snippet.strip()
511
+ if snippet and snippet not in {"{}", "[]"}:
512
+ message = snippet
513
+
514
+ # Map status codes to exceptions
515
+ error_mapping: dict[int, type[AffinityError]] = {
516
+ 400: ValidationError,
517
+ 401: AuthenticationError,
518
+ 403: AuthorizationError,
519
+ 404: NotFoundError,
520
+ 409: ConflictError,
521
+ 422: ValidationError,
522
+ 429: RateLimitError,
523
+ 500: ServerError,
524
+ 502: ServerError,
525
+ 503: ServerError,
526
+ 504: ServerError,
527
+ }
528
+
529
+ error_class = error_mapping.get(status_code, AffinityError)
530
+
531
+ # Special handling for ValidationError with param
532
+ if error_class is ValidationError:
533
+ return ValidationError(
534
+ message,
535
+ param=param,
536
+ status_code=status_code,
537
+ response_body=response_body,
538
+ diagnostics=diagnostics,
539
+ )
540
+
541
+ # Special handling for RateLimitError with retry_after
542
+ if error_class is RateLimitError:
543
+ return RateLimitError(
544
+ message,
545
+ retry_after=retry_after,
546
+ status_code=status_code,
547
+ response_body=response_body,
548
+ diagnostics=diagnostics,
549
+ )
550
+
551
+ return error_class(
552
+ message,
553
+ status_code=status_code,
554
+ response_body=response_body,
555
+ diagnostics=diagnostics,
556
+ )
557
+
558
+
559
+ # =============================================================================
560
+ # Webhook parsing errors (inbound webhook helpers)
561
+ # =============================================================================
562
+
563
+
564
+ class WebhookParseError(AffinityError):
565
+ """Base error for inbound webhook parsing/validation failures."""
566
+
567
+ pass
568
+
569
+
570
+ class WebhookInvalidJsonError(WebhookParseError):
571
+ """Raised when a webhook payload cannot be decoded as JSON."""
572
+
573
+ pass
574
+
575
+
576
+ class WebhookInvalidPayloadError(WebhookParseError):
577
+ """Raised when a decoded webhook payload is not in the expected envelope shape."""
578
+
579
+ pass
580
+
581
+
582
+ class WebhookMissingKeyError(WebhookParseError):
583
+ """Raised when a webhook payload is missing a required key."""
584
+
585
+ def __init__(self, message: str, *, key: str):
586
+ super().__init__(message)
587
+ self.key = key
588
+
589
+
590
+ class WebhookInvalidSentAtError(WebhookParseError):
591
+ """Raised when a webhook `sent_at` field is missing or invalid."""
592
+
593
+ pass
594
+
595
+
596
+ # =============================================================================
597
+ # Filter Parsing Errors
598
+ # =============================================================================
599
+
600
+
601
+ class FilterParseError(ValueError):
602
+ """
603
+ Raised when a filter expression cannot be parsed.
604
+
605
+ Common causes:
606
+ - Multi-word values not quoted: Status=Intro Meeting
607
+ - Invalid operators
608
+ - Malformed expressions
609
+
610
+ Example fix:
611
+ # Wrong: --filter 'Status=Intro Meeting'
612
+ # Right: --filter 'Status="Intro Meeting"'
613
+ """
614
+
615
+ pass