affinity-sdk 0.9.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,663 @@
1
+ """
2
+ Strongly-typed IDs and core type definitions for the Affinity API.
3
+
4
+ This module provides type-safe ID wrappers to prevent mixing up different entity IDs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from datetime import datetime, timezone
11
+ from enum import Enum, IntEnum
12
+ from typing import Annotated, Any, SupportsInt, TypeAlias, cast
13
+
14
+ from pydantic import AfterValidator, Field, GetCoreSchemaHandler
15
+ from pydantic_core import CoreSchema, core_schema
16
+
17
+ # =============================================================================
18
+ # Typed IDs - These provide type safety to prevent mixing up different entity IDs
19
+ # =============================================================================
20
+
21
+
22
+ class IntId(int):
23
+ @classmethod
24
+ def __get_pydantic_core_schema__(
25
+ cls, source_type: Any, handler: GetCoreSchemaHandler
26
+ ) -> CoreSchema:
27
+ _ = source_type
28
+ return core_schema.no_info_after_validator_function(cls, handler(int))
29
+
30
+
31
+ class StrId(str):
32
+ @classmethod
33
+ def __get_pydantic_core_schema__(
34
+ cls, source_type: Any, handler: GetCoreSchemaHandler
35
+ ) -> CoreSchema:
36
+ _ = source_type
37
+ return core_schema.no_info_after_validator_function(cls, handler(str))
38
+
39
+
40
+ class PersonId(IntId):
41
+ pass
42
+
43
+
44
+ class CompanyId(IntId):
45
+ """Called Organization in V1."""
46
+
47
+
48
+ class OpportunityId(IntId):
49
+ pass
50
+
51
+
52
+ class ListId(IntId):
53
+ pass
54
+
55
+
56
+ class ListEntryId(IntId):
57
+ pass
58
+
59
+
60
+ _FIELD_ID_RE = re.compile(r"^field-(\d+)$")
61
+
62
+
63
+ class FieldId(StrId):
64
+ """
65
+ V2-style field id (e.g. 'field-123').
66
+
67
+ FieldId provides normalized comparison semantics:
68
+ - ``FieldId(123) == FieldId("123")`` → ``True``
69
+ - ``FieldId("field-123") == "field-123"`` → ``True``
70
+ - ``FieldId("field-123") == 123`` → ``True``
71
+
72
+ This normalization is specific to FieldId because field IDs uniquely come
73
+ from mixed sources (some APIs return integers, some return strings like
74
+ "field-123"). Other TypedId subclasses (PersonId, CompanyId, etc.) don't
75
+ have this problem - they consistently use integers.
76
+ """
77
+
78
+ def __new__(cls, value: Any) -> FieldId:
79
+ """Normalize value to 'field-xxx' format at construction time."""
80
+ if isinstance(value, cls):
81
+ return value
82
+ if isinstance(value, int):
83
+ return str.__new__(cls, f"field-{value}")
84
+ if isinstance(value, str):
85
+ candidate = value.strip()
86
+ if candidate.isdigit():
87
+ return str.__new__(cls, f"field-{candidate}")
88
+ if _FIELD_ID_RE.match(candidate):
89
+ return str.__new__(cls, candidate)
90
+ raise ValueError("FieldId must be an int, digits, or 'field-<digits>'")
91
+
92
+ @classmethod
93
+ def __get_pydantic_core_schema__(
94
+ cls, source_type: Any, handler: GetCoreSchemaHandler
95
+ ) -> CoreSchema:
96
+ _ = source_type
97
+ _ = handler
98
+
99
+ def validate(value: Any) -> FieldId:
100
+ # Use __new__ which handles all normalization
101
+ return cls(value)
102
+
103
+ return core_schema.no_info_plain_validator_function(validate)
104
+
105
+ def __eq__(self, other: object) -> bool:
106
+ """
107
+ Normalize comparison for FieldId.
108
+
109
+ Supports comparison with:
110
+ - Other FieldId instances
111
+ - Strings (e.g., "field-123" or "123")
112
+ - Integers (e.g., 123)
113
+ """
114
+ if isinstance(other, FieldId):
115
+ # Both are FieldId - compare string representations
116
+ return str.__eq__(self, other)
117
+ if isinstance(other, str):
118
+ # Compare with string - could be "field-123" or "123"
119
+ try:
120
+ other_normalized = FieldId(other)
121
+ return str.__eq__(self, other_normalized)
122
+ except ValueError:
123
+ return False
124
+ if isinstance(other, int):
125
+ # Compare with integer
126
+ return str.__eq__(self, f"field-{other}")
127
+ return NotImplemented
128
+
129
+ def __hash__(self) -> int:
130
+ """Hash the string representation for dict/set usage."""
131
+ return str.__hash__(self)
132
+
133
+ def __repr__(self) -> str:
134
+ """Return a representation useful for debugging."""
135
+ return f"FieldId({str.__repr__(self)})"
136
+
137
+ def __str__(self) -> str:
138
+ """Return the canonical string value (e.g., 'field-123')."""
139
+ return str.__str__(self)
140
+
141
+
142
+ class FieldValueId(IntId):
143
+ pass
144
+
145
+
146
+ class NoteId(IntId):
147
+ pass
148
+
149
+
150
+ class ReminderIdType(IntId):
151
+ pass
152
+
153
+
154
+ class WebhookId(IntId):
155
+ pass
156
+
157
+
158
+ class InteractionId(IntId):
159
+ pass
160
+
161
+
162
+ class FileId(IntId):
163
+ pass
164
+
165
+
166
+ class SavedViewId(IntId):
167
+ pass
168
+
169
+
170
+ class DropdownOptionId(IntId):
171
+ pass
172
+
173
+
174
+ class UserId(IntId):
175
+ pass
176
+
177
+
178
+ class TenantId(IntId):
179
+ pass
180
+
181
+
182
+ class FieldValueChangeId(IntId):
183
+ pass
184
+
185
+
186
+ class TaskId(StrId):
187
+ """UUIDs for async tasks."""
188
+
189
+
190
+ class EnrichedFieldId(StrId):
191
+ """Enriched field IDs are strings in V2 (e.g., 'affinity-data-description')."""
192
+
193
+
194
+ # Combined Field ID type - can be either numeric or string
195
+ AnyFieldId: TypeAlias = FieldId | EnrichedFieldId
196
+
197
+
198
+ def field_id_to_v1_numeric(field_id: AnyFieldId) -> int:
199
+ """
200
+ Convert v2 FieldId into v1 numeric field_id.
201
+
202
+ Accepts:
203
+ - FieldId('field-123') -> 123
204
+ Rejects:
205
+ - EnrichedFieldId(...) (cannot be represented as v1 numeric id)
206
+ """
207
+ if isinstance(field_id, EnrichedFieldId):
208
+ raise ValueError(
209
+ "Field IDs must be 'field-<digits>' for v1 conversion; "
210
+ "enriched/relationship-intelligence IDs (e.g., 'affinity-data-*', "
211
+ "'source-of-introduction') are not supported."
212
+ )
213
+
214
+ match = _FIELD_ID_RE.match(str(field_id))
215
+ if match is None:
216
+ raise ValueError(
217
+ "Field IDs must be 'field-<digits>' for v1 conversion; "
218
+ "enriched/relationship-intelligence IDs (e.g., 'affinity-data-*', "
219
+ "'source-of-introduction') are not supported."
220
+ )
221
+ return int(match.group(1))
222
+
223
+
224
+ # =============================================================================
225
+ # Enums - Replace all magic numbers with type-safe enums
226
+ # =============================================================================
227
+
228
+
229
+ class OpenIntEnum(IntEnum):
230
+ @classmethod
231
+ def _missing_(cls, value: object) -> OpenIntEnum:
232
+ try:
233
+ int_value = int(cast(SupportsInt | str | bytes | bytearray, value))
234
+ except (TypeError, ValueError) as e:
235
+ raise ValueError(value) from e
236
+
237
+ obj = int.__new__(cls, int_value)
238
+ obj._value_ = int_value
239
+ obj._name_ = f"UNKNOWN_{int_value}"
240
+ cls._value2member_map_[int_value] = obj
241
+ return obj
242
+
243
+
244
+ class OpenStrEnum(str, Enum):
245
+ @classmethod
246
+ def _missing_(cls, value: object) -> OpenStrEnum:
247
+ text = str(value)
248
+ obj = str.__new__(cls, text)
249
+ obj._value_ = text
250
+ obj._name_ = f"UNKNOWN_{text}"
251
+ cls._value2member_map_[text] = obj
252
+ return obj
253
+
254
+ def __str__(self) -> str:
255
+ return str(self.value)
256
+
257
+
258
+ class ListType(OpenIntEnum):
259
+ """Type of entities a list can contain."""
260
+
261
+ PERSON = 0
262
+ COMPANY = 1
263
+ OPPORTUNITY = 8
264
+
265
+ # V1 compatibility alias - prefer COMPANY in new code
266
+ ORGANIZATION = COMPANY
267
+
268
+ @classmethod
269
+ def _missing_(cls, value: object) -> OpenIntEnum:
270
+ # V2 list endpoints commonly return string types (e.g. "company").
271
+ if isinstance(value, str):
272
+ text = value.strip().lower()
273
+ if text in ("person", "people"):
274
+ return cls.PERSON
275
+ if text in ("company", "organization", "organisation"):
276
+ return cls.COMPANY
277
+ if text in ("opportunity", "opportunities"):
278
+ return cls.OPPORTUNITY
279
+ return super()._missing_(value)
280
+
281
+
282
+ class EntityType(OpenIntEnum):
283
+ """Entity types in Affinity."""
284
+
285
+ PERSON = 0
286
+ ORGANIZATION = 1
287
+ OPPORTUNITY = 8
288
+
289
+
290
+ class PersonType(OpenStrEnum):
291
+ """Types of persons in Affinity."""
292
+
293
+ INTERNAL = "internal"
294
+ EXTERNAL = "external"
295
+ COLLABORATOR = "collaborator"
296
+
297
+
298
+ class FieldValueType(OpenStrEnum):
299
+ """
300
+ Field value types (V2-first).
301
+
302
+ V2 represents `valueType` as strings (e.g. "dropdown-multi", "ranked-dropdown", "interaction").
303
+ V1 represents field value types as numeric codes; numeric inputs are normalized into the closest
304
+ V2 string type where possible.
305
+ """
306
+
307
+ TEXT = "text"
308
+
309
+ NUMBER = "number"
310
+ NUMBER_MULTI = "number-multi"
311
+
312
+ DATETIME = "datetime" # V2 canonical (V1 docs call this "Date")
313
+
314
+ LOCATION = "location"
315
+ LOCATION_MULTI = "location-multi"
316
+
317
+ DROPDOWN = "dropdown"
318
+ DROPDOWN_MULTI = "dropdown-multi"
319
+ RANKED_DROPDOWN = "ranked-dropdown"
320
+
321
+ PERSON = "person"
322
+ PERSON_MULTI = "person-multi"
323
+
324
+ COMPANY = "company" # V1 calls this "organization"
325
+ COMPANY_MULTI = "company-multi"
326
+
327
+ FILTERABLE_TEXT = "filterable-text"
328
+ FILTERABLE_TEXT_MULTI = "filterable-text-multi"
329
+
330
+ INTERACTION = "interaction" # V2-only (relationship-intelligence)
331
+
332
+ @classmethod
333
+ def _missing_(cls, value: object) -> OpenStrEnum:
334
+ # Normalize known V1 numeric codes to canonical V2 strings.
335
+ if isinstance(value, int):
336
+ mapping: dict[int, FieldValueType] = {
337
+ 0: cls.PERSON,
338
+ 1: cls.COMPANY,
339
+ 2: cls.TEXT,
340
+ 3: cls.NUMBER,
341
+ 4: cls.DATETIME,
342
+ 5: cls.LOCATION,
343
+ 7: cls.DROPDOWN,
344
+ 10: cls.FILTERABLE_TEXT,
345
+ }
346
+ if value in mapping:
347
+ return mapping[value]
348
+
349
+ # Keep "unknown numeric" inputs stable by caching under the int key as well.
350
+ text = str(value)
351
+ existing = cls._value2member_map_.get(text)
352
+ if existing is not None:
353
+ existing_enum = cast(OpenStrEnum, existing)
354
+ cls._value2member_map_[value] = existing_enum
355
+ return existing_enum
356
+ created = super()._missing_(text)
357
+ cls._value2member_map_[value] = created
358
+ return created
359
+
360
+ if isinstance(value, str):
361
+ text = value.strip()
362
+ lowered = text.lower()
363
+ if lowered == "date":
364
+ return cls.DATETIME
365
+ if lowered in ("organization", "organisation"):
366
+ return cls.COMPANY
367
+ if lowered in ("organization-multi", "organisation-multi"):
368
+ return cls.COMPANY_MULTI
369
+ if lowered == "filterable_text":
370
+ return cls.FILTERABLE_TEXT
371
+
372
+ return super()._missing_(value)
373
+
374
+
375
+ def to_v1_value_type_code(
376
+ *,
377
+ value_type: FieldValueType,
378
+ raw: str | int | None = None,
379
+ ) -> int | None:
380
+ """
381
+ Convert a V2-first `FieldValueType` into a V1 numeric code (when possible).
382
+
383
+ Notes:
384
+ - If `raw` is already a known V1 numeric code, it is returned as-is.
385
+ - `interaction` is V2-only and has no V1 equivalent; returns None.
386
+ """
387
+
388
+ if isinstance(raw, int):
389
+ if raw in (0, 1, 2, 3, 4, 5, 7, 10):
390
+ return raw
391
+ return raw
392
+
393
+ match value_type:
394
+ case FieldValueType.PERSON | FieldValueType.PERSON_MULTI:
395
+ return 0
396
+ case FieldValueType.COMPANY | FieldValueType.COMPANY_MULTI:
397
+ return 1
398
+ case FieldValueType.TEXT:
399
+ return 2
400
+ case FieldValueType.NUMBER | FieldValueType.NUMBER_MULTI:
401
+ return 3
402
+ case FieldValueType.DATETIME:
403
+ return 4
404
+ case FieldValueType.LOCATION | FieldValueType.LOCATION_MULTI:
405
+ return 5
406
+ case (
407
+ FieldValueType.DROPDOWN | FieldValueType.DROPDOWN_MULTI | FieldValueType.RANKED_DROPDOWN
408
+ ):
409
+ return 7
410
+ case FieldValueType.FILTERABLE_TEXT | FieldValueType.FILTERABLE_TEXT_MULTI:
411
+ return 10
412
+ case FieldValueType.INTERACTION:
413
+ return None
414
+ case _:
415
+ return None
416
+
417
+
418
+ class FieldType(OpenStrEnum):
419
+ """
420
+ Field types based on their source/scope.
421
+ V2 API uses these string identifiers.
422
+ """
423
+
424
+ ENRICHED = "enriched"
425
+ LIST = "list"
426
+ LIST_SPECIFIC = "list-specific" # Alias used in some API responses
427
+ GLOBAL = "global"
428
+ RELATIONSHIP_INTELLIGENCE = "relationship-intelligence"
429
+
430
+
431
+ class InteractionType(OpenIntEnum):
432
+ """Types of interactions."""
433
+
434
+ MEETING = 0 # Also called Event
435
+ CALL = 1
436
+ CHAT_MESSAGE = 2
437
+ EMAIL = 3
438
+
439
+
440
+ class InteractionDirection(OpenIntEnum):
441
+ """Direction of communication for interactions."""
442
+
443
+ OUTGOING = 0
444
+ INCOMING = 1
445
+
446
+
447
+ class InteractionLoggingType(OpenIntEnum):
448
+ """How the interaction was logged."""
449
+
450
+ AUTOMATIC = 0
451
+ MANUAL = 1
452
+
453
+
454
+ class ReminderType(OpenIntEnum):
455
+ """Types of reminders."""
456
+
457
+ ONE_TIME = 0
458
+ RECURRING = 1
459
+
460
+
461
+ class ReminderResetType(OpenIntEnum):
462
+ """How recurring reminders get reset."""
463
+
464
+ INTERACTION = 0 # Email or meeting
465
+ EMAIL = 1
466
+ MEETING = 2
467
+
468
+
469
+ class ReminderStatus(OpenIntEnum):
470
+ """Current status of a reminder."""
471
+
472
+ COMPLETED = 0
473
+ ACTIVE = 1
474
+ OVERDUE = 2
475
+
476
+
477
+ class NoteType(OpenIntEnum):
478
+ """Types of notes."""
479
+
480
+ PLAIN_TEXT = 0
481
+ EMAIL_DERIVED = 1 # Deprecated creation method
482
+ HTML = 2
483
+ AI_NOTETAKER = 3
484
+
485
+
486
+ class ListRole(OpenIntEnum):
487
+ """Roles for list-level permissions."""
488
+
489
+ ADMIN = 0
490
+ BASIC = 1
491
+ STANDARD = 2
492
+
493
+
494
+ class FieldValueChangeAction(OpenIntEnum):
495
+ """Types of changes that can occur to field values."""
496
+
497
+ CREATE = 0
498
+ DELETE = 1
499
+ UPDATE = 2
500
+
501
+
502
+ class WebhookEvent(OpenStrEnum):
503
+ """
504
+ Supported webhook events (27 total).
505
+
506
+ Events cover CRUD operations on Affinity entities:
507
+
508
+ - **Lists**: created, updated, deleted
509
+ - **List entries**: created, deleted
510
+ - **Notes**: created, updated, deleted
511
+ - **Fields**: created, updated, deleted
512
+ - **Field values**: created, updated, deleted
513
+ - **Persons**: created, updated, deleted
514
+ - **Organizations (companies)**: created, updated, deleted, merged
515
+ - **Opportunities**: created, updated, deleted
516
+ - **Files**: created, deleted
517
+ - **Reminders**: created, updated, deleted
518
+
519
+ This enum extends ``OpenStrEnum`` for forward compatibility - any unknown
520
+ events from Affinity are preserved as strings rather than raising errors.
521
+
522
+ See the webhooks guide for complete documentation and usage examples.
523
+ """
524
+
525
+ LIST_CREATED = "list.created"
526
+ LIST_UPDATED = "list.updated"
527
+ LIST_DELETED = "list.deleted"
528
+ LIST_ENTRY_CREATED = "list_entry.created"
529
+ LIST_ENTRY_DELETED = "list_entry.deleted"
530
+ NOTE_CREATED = "note.created"
531
+ NOTE_UPDATED = "note.updated"
532
+ NOTE_DELETED = "note.deleted"
533
+ FIELD_CREATED = "field.created"
534
+ FIELD_UPDATED = "field.updated"
535
+ FIELD_DELETED = "field.deleted"
536
+ FIELD_VALUE_CREATED = "field_value.created"
537
+ FIELD_VALUE_UPDATED = "field_value.updated"
538
+ FIELD_VALUE_DELETED = "field_value.deleted"
539
+ PERSON_CREATED = "person.created"
540
+ PERSON_UPDATED = "person.updated"
541
+ PERSON_DELETED = "person.deleted"
542
+ ORGANIZATION_CREATED = "organization.created"
543
+ ORGANIZATION_UPDATED = "organization.updated"
544
+ ORGANIZATION_DELETED = "organization.deleted"
545
+ ORGANIZATION_MERGED = "organization.merged"
546
+ OPPORTUNITY_CREATED = "opportunity.created"
547
+ OPPORTUNITY_UPDATED = "opportunity.updated"
548
+ OPPORTUNITY_DELETED = "opportunity.deleted"
549
+ FILE_CREATED = "file.created"
550
+ FILE_DELETED = "file.deleted"
551
+ REMINDER_CREATED = "reminder.created"
552
+ REMINDER_UPDATED = "reminder.updated"
553
+ REMINDER_DELETED = "reminder.deleted"
554
+
555
+
556
+ class DropdownOptionColor(IntEnum):
557
+ """
558
+ Colors for dropdown options.
559
+
560
+ Affinity uses integer color codes for dropdown field options.
561
+ """
562
+
563
+ DEFAULT = 0
564
+ BLUE = 1
565
+ GREEN = 2
566
+ YELLOW = 3
567
+ ORANGE = 4
568
+ RED = 5
569
+ PURPLE = 6
570
+ GRAY = 7
571
+
572
+
573
+ class MergeStatus(str, Enum):
574
+ """Status of async merge operations."""
575
+
576
+ PENDING = "pending"
577
+ IN_PROGRESS = "in_progress"
578
+ SUCCESS = "success"
579
+ FAILED = "failed"
580
+
581
+
582
+ # =============================================================================
583
+ # API Version tracking
584
+ # =============================================================================
585
+
586
+
587
+ class APIVersion(str, Enum):
588
+ """API versions with their base URLs."""
589
+
590
+ V1 = "v1"
591
+ V2 = "v2"
592
+
593
+
594
+ # Base URLs
595
+ V1_BASE_URL = "https://api.affinity.co"
596
+ V2_BASE_URL = "https://api.affinity.co/v2"
597
+
598
+
599
+ # =============================================================================
600
+ # Common type aliases with validation
601
+ # =============================================================================
602
+
603
+ PositiveInt = Annotated[int, Field(gt=0)]
604
+ NonNegativeInt = Annotated[int, Field(ge=0)]
605
+ EmailStr = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
606
+
607
+ # =============================================================================
608
+ # Datetime with UTC normalization
609
+ # =============================================================================
610
+
611
+
612
+ def _normalize_to_utc(v: datetime) -> datetime:
613
+ """
614
+ Normalize datetime to UTC-aware.
615
+
616
+ This validator ensures all ISODatetime values are:
617
+ 1. Timezone-aware (never naive)
618
+ 2. Normalized to UTC
619
+
620
+ Input handling:
621
+ - Naive datetime: Assumed UTC, tzinfo added
622
+ - UTC datetime: Passed through unchanged
623
+ - Non-UTC aware: Converted to UTC equivalent
624
+
625
+ This guarantees ISODatetime values are always directly comparable
626
+ without risk of TypeError from naive/aware mixing.
627
+
628
+ Note: This differs from CLI input parsing (parse_iso_datetime)
629
+ which interprets naive strings as local time for user convenience.
630
+ SDK uses UTC assumption because API responses are always UTC.
631
+ """
632
+ if v.tzinfo is None:
633
+ return v.replace(tzinfo=timezone.utc)
634
+ return v.astimezone(timezone.utc)
635
+
636
+
637
+ # Datetime with UTC normalization - all values guaranteed UTC-aware
638
+ # IMPORTANT: Use AfterValidator, not BeforeValidator!
639
+ # BeforeValidator runs before Pydantic's type coercion, so input could be a string.
640
+ # AfterValidator runs after Pydantic parses the string to datetime.
641
+ ISODatetime = Annotated[datetime, AfterValidator(_normalize_to_utc)]
642
+
643
+
644
+ # =============================================================================
645
+ # Filter operators for V2 API query language
646
+ # =============================================================================
647
+
648
+
649
+ class FilterOperator(str, Enum):
650
+ """Operators for V2 filtering."""
651
+
652
+ EQUALS = "="
653
+ NOT_EQUALS = "!="
654
+ STARTS_WITH = "=^"
655
+ ENDS_WITH = "=$"
656
+ CONTAINS = "=~"
657
+ GREATER_THAN = ">"
658
+ GREATER_THAN_OR_EQUAL = ">="
659
+ LESS_THAN = "<"
660
+ LESS_THAN_OR_EQUAL = "<="
661
+ IS_NULL = "!= *"
662
+ IS_NOT_NULL = "= *"
663
+ IS_EMPTY = '= ""'
affinity/policies.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ Client policies (cross-cutting behavioral controls).
3
+
4
+ Policies are orthogonal and composable. They are enforced centrally by the HTTP
5
+ request pipeline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+
13
+
14
+ class WritePolicy(Enum):
15
+ """Whether the SDK is allowed to perform write operations."""
16
+
17
+ ALLOW = "allow"
18
+ DENY = "deny"
19
+
20
+
21
+ class ExternalHookPolicy(Enum):
22
+ """
23
+ Controls hook/event emission for requests to external hosts (e.g., signed URLs).
24
+
25
+ - SUPPRESS: do not emit hook events for external hops
26
+ - REDACT: emit events but redact external URLs (drop query/fragment)
27
+ - EMIT_UNSAFE: emit full external URLs (unsafe; may leak signed query params)
28
+ """
29
+
30
+ SUPPRESS = "suppress"
31
+ REDACT = "redact"
32
+ EMIT_UNSAFE = "emit_unsafe"
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Policies:
37
+ """Policy bundle applied to all requests made by a client."""
38
+
39
+ write: WritePolicy = WritePolicy.ALLOW
40
+ external_hooks: ExternalHookPolicy = ExternalHookPolicy.REDACT