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/models/types.py
ADDED
|
@@ -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
|