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/hooks.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public hook types for request/response instrumentation (DX-008).
|
|
3
|
+
|
|
4
|
+
Long-term, the preferred API is `on_event(HookEvent)` which can represent retries,
|
|
5
|
+
redirects, and streaming lifecycles without ambiguity. The older
|
|
6
|
+
`on_request/on_response/on_error` hooks remain available as adapters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal, TypeAlias
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# Legacy 3-hook API (adapters)
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class RequestInfo:
|
|
22
|
+
"""
|
|
23
|
+
Sanitized request metadata for hooks.
|
|
24
|
+
|
|
25
|
+
Note: authentication is intentionally excluded.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
method: str
|
|
29
|
+
url: str
|
|
30
|
+
headers: dict[str, str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ResponseInfo:
|
|
35
|
+
"""Sanitized response metadata for hooks."""
|
|
36
|
+
|
|
37
|
+
status_code: int
|
|
38
|
+
headers: dict[str, str]
|
|
39
|
+
elapsed_ms: float
|
|
40
|
+
request: RequestInfo
|
|
41
|
+
cache_hit: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ErrorInfo:
|
|
46
|
+
"""Sanitized error metadata for hooks."""
|
|
47
|
+
|
|
48
|
+
error: BaseException
|
|
49
|
+
elapsed_ms: float
|
|
50
|
+
request: RequestInfo
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
RequestHook: TypeAlias = Callable[[RequestInfo], None]
|
|
54
|
+
ResponseHook: TypeAlias = Callable[[ResponseInfo], None]
|
|
55
|
+
ErrorHook: TypeAlias = Callable[[ErrorInfo], None]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Event-based hook API
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class RequestStarted:
|
|
65
|
+
client_request_id: str
|
|
66
|
+
request: RequestInfo
|
|
67
|
+
api_version: Literal["v1", "v2", "external"]
|
|
68
|
+
type: Literal["request_started"] = "request_started"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class RequestRetrying:
|
|
73
|
+
client_request_id: str
|
|
74
|
+
request: RequestInfo
|
|
75
|
+
attempt: int
|
|
76
|
+
wait_seconds: float
|
|
77
|
+
reason: str
|
|
78
|
+
type: Literal["request_retrying"] = "request_retrying"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class RedirectFollowed:
|
|
83
|
+
client_request_id: str
|
|
84
|
+
from_url: str
|
|
85
|
+
to_url: str
|
|
86
|
+
hop: int
|
|
87
|
+
external: bool
|
|
88
|
+
type: Literal["redirect_followed"] = "redirect_followed"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class ResponseHeadersReceived:
|
|
93
|
+
client_request_id: str
|
|
94
|
+
request: RequestInfo
|
|
95
|
+
status_code: int
|
|
96
|
+
headers: list[tuple[str, str]]
|
|
97
|
+
elapsed_ms: float
|
|
98
|
+
external: bool
|
|
99
|
+
cache_hit: bool
|
|
100
|
+
request_id: str | None = None
|
|
101
|
+
type: Literal["response_headers_received"] = "response_headers_received"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class RequestFailed:
|
|
106
|
+
client_request_id: str
|
|
107
|
+
request: RequestInfo
|
|
108
|
+
error: BaseException
|
|
109
|
+
elapsed_ms: float
|
|
110
|
+
external: bool
|
|
111
|
+
type: Literal["request_failed"] = "request_failed"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class RequestSucceeded:
|
|
116
|
+
client_request_id: str
|
|
117
|
+
request: RequestInfo
|
|
118
|
+
status_code: int
|
|
119
|
+
elapsed_ms: float
|
|
120
|
+
external: bool
|
|
121
|
+
type: Literal["request_succeeded"] = "request_succeeded"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class StreamCompleted:
|
|
126
|
+
client_request_id: str
|
|
127
|
+
request: RequestInfo
|
|
128
|
+
bytes_read: int
|
|
129
|
+
bytes_total: int | None
|
|
130
|
+
elapsed_ms: float
|
|
131
|
+
external: bool
|
|
132
|
+
type: Literal["stream_completed"] = "stream_completed"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class StreamAborted:
|
|
137
|
+
client_request_id: str
|
|
138
|
+
request: RequestInfo
|
|
139
|
+
reason: str
|
|
140
|
+
bytes_read: int
|
|
141
|
+
bytes_total: int | None
|
|
142
|
+
elapsed_ms: float
|
|
143
|
+
external: bool
|
|
144
|
+
type: Literal["stream_aborted"] = "stream_aborted"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class StreamFailed:
|
|
149
|
+
client_request_id: str
|
|
150
|
+
request: RequestInfo
|
|
151
|
+
error: BaseException
|
|
152
|
+
bytes_read: int
|
|
153
|
+
bytes_total: int | None
|
|
154
|
+
elapsed_ms: float
|
|
155
|
+
external: bool
|
|
156
|
+
type: Literal["stream_failed"] = "stream_failed"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
HookEvent: TypeAlias = (
|
|
160
|
+
RequestStarted
|
|
161
|
+
| RequestRetrying
|
|
162
|
+
| RedirectFollowed
|
|
163
|
+
| ResponseHeadersReceived
|
|
164
|
+
| RequestFailed
|
|
165
|
+
| RequestSucceeded
|
|
166
|
+
| StreamCompleted
|
|
167
|
+
| StreamAborted
|
|
168
|
+
| StreamFailed
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
EventHook: TypeAlias = Callable[[HookEvent], None]
|
|
172
|
+
AsyncEventHook: TypeAlias = Callable[[HookEvent], Awaitable[None]]
|
|
173
|
+
AnyEventHook: TypeAlias = Callable[[HookEvent], None | Awaitable[None]]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
# Legacy 3-hook API
|
|
178
|
+
"RequestInfo",
|
|
179
|
+
"ResponseInfo",
|
|
180
|
+
"ErrorInfo",
|
|
181
|
+
"RequestHook",
|
|
182
|
+
"ResponseHook",
|
|
183
|
+
"ErrorHook",
|
|
184
|
+
# Event-based API
|
|
185
|
+
"HookEvent",
|
|
186
|
+
"EventHook",
|
|
187
|
+
"AsyncEventHook",
|
|
188
|
+
"AnyEventHook",
|
|
189
|
+
"RequestStarted",
|
|
190
|
+
"RequestRetrying",
|
|
191
|
+
"RedirectFollowed",
|
|
192
|
+
"ResponseHeadersReceived",
|
|
193
|
+
"RequestFailed",
|
|
194
|
+
"RequestSucceeded",
|
|
195
|
+
"StreamCompleted",
|
|
196
|
+
"StreamAborted",
|
|
197
|
+
"StreamFailed",
|
|
198
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inbound webhook parsing helpers.
|
|
3
|
+
|
|
4
|
+
The SDK manages webhook subscriptions via the V1 API (`client.webhooks`). This module
|
|
5
|
+
provides optional, framework-agnostic helpers for parsing inbound webhook payloads
|
|
6
|
+
sent by Affinity to your webhook URL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
WebhookInvalidJsonError,
|
|
21
|
+
WebhookInvalidPayloadError,
|
|
22
|
+
WebhookInvalidSentAtError,
|
|
23
|
+
WebhookMissingKeyError,
|
|
24
|
+
)
|
|
25
|
+
from .types import WebhookEvent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _WebhookModel(BaseModel):
|
|
29
|
+
model_config = ConfigDict(
|
|
30
|
+
extra="ignore",
|
|
31
|
+
populate_by_name=True,
|
|
32
|
+
validate_assignment=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebhookEnvelope(_WebhookModel):
|
|
37
|
+
"""
|
|
38
|
+
Parsed webhook envelope.
|
|
39
|
+
|
|
40
|
+
Affinity webhook requests use the envelope shape:
|
|
41
|
+
- `type`: event string (e.g., "list_entry.created")
|
|
42
|
+
- `body`: event-specific payload object
|
|
43
|
+
- `sent_at`: unix epoch seconds (UTC)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
type: WebhookEvent
|
|
47
|
+
body: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
sent_at: datetime
|
|
49
|
+
sent_at_epoch: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WebhookPerson(_WebhookModel):
|
|
53
|
+
id: int
|
|
54
|
+
type: int | None = None
|
|
55
|
+
first_name: str | None = None
|
|
56
|
+
last_name: str | None = None
|
|
57
|
+
primary_email: str | None = None
|
|
58
|
+
emails: list[str] = Field(default_factory=list)
|
|
59
|
+
company_ids: list[int] | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
validation_alias=AliasChoices("organization_ids", "organizationIds"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WebhookOrganization(_WebhookModel):
|
|
66
|
+
id: int
|
|
67
|
+
name: str | None = None
|
|
68
|
+
domain: str | None = None
|
|
69
|
+
domains: list[str] = Field(default_factory=list)
|
|
70
|
+
crunchbase_uuid: str | None = None
|
|
71
|
+
global_: bool | None = Field(None, alias="global")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OrganizationMergedBody(_WebhookModel):
|
|
75
|
+
changer: WebhookPerson | None = None
|
|
76
|
+
removed_company: WebhookOrganization | None = None
|
|
77
|
+
company: WebhookOrganization | None = None
|
|
78
|
+
merged_at: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ListEntryCreatedBody(_WebhookModel):
|
|
82
|
+
id: int
|
|
83
|
+
list_id: int | None = None
|
|
84
|
+
creator_id: int | None = None
|
|
85
|
+
entity_id: int | None = None
|
|
86
|
+
entity_type: int | None = None
|
|
87
|
+
created_at: str | None = None
|
|
88
|
+
entity: dict[str, Any] | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FieldValueUpdatedBody(_WebhookModel):
|
|
92
|
+
id: int
|
|
93
|
+
field_id: int | None = None
|
|
94
|
+
list_entry_id: int | None = None
|
|
95
|
+
entity_type: int | None = None
|
|
96
|
+
value_type: int | None = None
|
|
97
|
+
entity_id: int | None = None
|
|
98
|
+
value: Any | None = None
|
|
99
|
+
field: dict[str, Any] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
TBody = TypeVar("TBody")
|
|
103
|
+
BodyParser = Callable[[dict[str, Any]], Any]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class ParsedWebhook(Generic[TBody]):
|
|
108
|
+
type: WebhookEvent
|
|
109
|
+
body: TBody
|
|
110
|
+
sent_at: datetime
|
|
111
|
+
sent_at_epoch: int
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_json_payload(payload: bytes | str) -> Any:
|
|
115
|
+
if isinstance(payload, bytes):
|
|
116
|
+
try:
|
|
117
|
+
payload = payload.decode("utf-8")
|
|
118
|
+
except UnicodeDecodeError as e:
|
|
119
|
+
raise WebhookInvalidJsonError("Webhook payload bytes are not valid UTF-8") from e
|
|
120
|
+
try:
|
|
121
|
+
return json.loads(payload)
|
|
122
|
+
except json.JSONDecodeError as e:
|
|
123
|
+
raise WebhookInvalidJsonError("Webhook payload is not valid JSON") from e
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _require_key(data: Mapping[str, Any], key: str) -> Any:
|
|
127
|
+
if key not in data:
|
|
128
|
+
raise WebhookMissingKeyError(f"Webhook payload is missing required key: {key}", key=key)
|
|
129
|
+
return data[key]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_sent_at_epoch(value: Any) -> int:
|
|
133
|
+
if isinstance(value, bool):
|
|
134
|
+
raise WebhookInvalidSentAtError("Webhook 'sent_at' must be an epoch seconds integer")
|
|
135
|
+
if isinstance(value, int):
|
|
136
|
+
return value
|
|
137
|
+
if isinstance(value, float):
|
|
138
|
+
if not value.is_integer():
|
|
139
|
+
raise WebhookInvalidSentAtError("Webhook 'sent_at' must be an integer epoch seconds")
|
|
140
|
+
return int(value)
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
text = value.strip()
|
|
143
|
+
if text.isdigit():
|
|
144
|
+
return int(text)
|
|
145
|
+
raise WebhookInvalidSentAtError("Webhook 'sent_at' must be an epoch seconds integer")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_DEFAULT_WEBHOOK_MAX_AGE_SECONDS = 300
|
|
149
|
+
_DEFAULT_WEBHOOK_FUTURE_SKEW_SECONDS = 120
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _normalize_now(now: datetime | None) -> datetime:
|
|
153
|
+
"""
|
|
154
|
+
Return current UTC time, or normalize provided datetime to UTC.
|
|
155
|
+
|
|
156
|
+
Handles naive datetimes defensively - callers may pass datetime objects
|
|
157
|
+
from sources outside ISODatetime-validated models (e.g., tests, manual
|
|
158
|
+
construction). This is intentional defense-in-depth.
|
|
159
|
+
"""
|
|
160
|
+
current = now or datetime.now(timezone.utc)
|
|
161
|
+
if current.tzinfo is None:
|
|
162
|
+
return current.replace(tzinfo=timezone.utc)
|
|
163
|
+
return current.astimezone(timezone.utc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_sent_at(
|
|
167
|
+
sent_at: datetime,
|
|
168
|
+
*,
|
|
169
|
+
now: datetime | None,
|
|
170
|
+
max_age_seconds: int | None,
|
|
171
|
+
max_future_skew_seconds: int | None,
|
|
172
|
+
) -> None:
|
|
173
|
+
current = _normalize_now(now)
|
|
174
|
+
if max_age_seconds is not None:
|
|
175
|
+
if max_age_seconds < 0:
|
|
176
|
+
raise ValueError("max_age_seconds must be >= 0")
|
|
177
|
+
oldest = current - timedelta(seconds=max_age_seconds)
|
|
178
|
+
if sent_at < oldest:
|
|
179
|
+
raise WebhookInvalidSentAtError(
|
|
180
|
+
"Webhook 'sent_at' is too old for the allowed replay window."
|
|
181
|
+
)
|
|
182
|
+
if max_future_skew_seconds is not None:
|
|
183
|
+
if max_future_skew_seconds < 0:
|
|
184
|
+
raise ValueError("max_future_skew_seconds must be >= 0")
|
|
185
|
+
latest = current + timedelta(seconds=max_future_skew_seconds)
|
|
186
|
+
if sent_at > latest:
|
|
187
|
+
raise WebhookInvalidSentAtError(
|
|
188
|
+
"Webhook 'sent_at' is too far in the future for the allowed clock skew."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def parse_webhook(
|
|
193
|
+
payload: bytes | str | Mapping[str, Any],
|
|
194
|
+
*,
|
|
195
|
+
max_age_seconds: int | None = _DEFAULT_WEBHOOK_MAX_AGE_SECONDS,
|
|
196
|
+
max_future_skew_seconds: int | None = _DEFAULT_WEBHOOK_FUTURE_SKEW_SECONDS,
|
|
197
|
+
now: datetime | None = None,
|
|
198
|
+
) -> WebhookEnvelope:
|
|
199
|
+
"""
|
|
200
|
+
Parse an inbound webhook payload into a `WebhookEnvelope`.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
payload: Raw request body as bytes/str, or an already-decoded dict.
|
|
204
|
+
max_age_seconds: Reject payloads older than this many seconds (None disables).
|
|
205
|
+
max_future_skew_seconds: Reject payloads too far in the future (None disables).
|
|
206
|
+
now: Override the current time (UTC) for testing.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
WebhookInvalidJsonError: If payload is not valid JSON (bytes/str inputs).
|
|
210
|
+
WebhookMissingKeyError: If required keys are missing.
|
|
211
|
+
WebhookInvalidPayloadError: If the decoded payload isn't a JSON object.
|
|
212
|
+
WebhookInvalidSentAtError: If `sent_at` is invalid or outside the allowed window.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
data: Any = _parse_json_payload(payload) if isinstance(payload, (bytes, str)) else payload
|
|
216
|
+
|
|
217
|
+
if not isinstance(data, Mapping):
|
|
218
|
+
raise WebhookInvalidPayloadError("Webhook payload must be a JSON object at the top level")
|
|
219
|
+
|
|
220
|
+
event_type = _require_key(data, "type")
|
|
221
|
+
body = _require_key(data, "body")
|
|
222
|
+
sent_at_raw = _require_key(data, "sent_at")
|
|
223
|
+
|
|
224
|
+
if not isinstance(body, Mapping):
|
|
225
|
+
raise WebhookInvalidPayloadError("Webhook 'body' must be a JSON object")
|
|
226
|
+
|
|
227
|
+
sent_at_epoch = _parse_sent_at_epoch(sent_at_raw)
|
|
228
|
+
sent_at = datetime.fromtimestamp(sent_at_epoch, tz=timezone.utc)
|
|
229
|
+
_validate_sent_at(
|
|
230
|
+
sent_at,
|
|
231
|
+
now=now,
|
|
232
|
+
max_age_seconds=max_age_seconds,
|
|
233
|
+
max_future_skew_seconds=max_future_skew_seconds,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return WebhookEnvelope.model_validate(
|
|
237
|
+
{
|
|
238
|
+
"type": event_type,
|
|
239
|
+
"body": dict(body),
|
|
240
|
+
"sent_at": sent_at,
|
|
241
|
+
"sent_at_epoch": sent_at_epoch,
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _coerce_handler(handler: type[BaseModel] | BodyParser) -> BodyParser:
|
|
247
|
+
if isinstance(handler, type) and issubclass(handler, BaseModel):
|
|
248
|
+
return lambda body: handler.model_validate(body)
|
|
249
|
+
return handler
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class BodyRegistry:
|
|
253
|
+
"""
|
|
254
|
+
Registry mapping webhook event types to body parsers.
|
|
255
|
+
|
|
256
|
+
Parsers should accept a JSON object (dict) and return either a Pydantic model
|
|
257
|
+
or any other parsed representation.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, handlers: Mapping[WebhookEvent, type[BaseModel] | BodyParser] | None = None):
|
|
261
|
+
self._handlers: dict[WebhookEvent, BodyParser] = {}
|
|
262
|
+
if handlers:
|
|
263
|
+
for event, handler in handlers.items():
|
|
264
|
+
self.register(event, handler)
|
|
265
|
+
|
|
266
|
+
def register(self, event: WebhookEvent | str, handler: type[BaseModel] | BodyParser) -> None:
|
|
267
|
+
self._handlers[WebhookEvent(event)] = _coerce_handler(handler)
|
|
268
|
+
|
|
269
|
+
def parse_body(self, event: WebhookEvent, body: dict[str, Any]) -> Any:
|
|
270
|
+
parser = self._handlers.get(event)
|
|
271
|
+
return parser(body) if parser else body
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
DEFAULT_BODY_REGISTRY = BodyRegistry(
|
|
275
|
+
{
|
|
276
|
+
WebhookEvent.ORGANIZATION_MERGED: OrganizationMergedBody,
|
|
277
|
+
WebhookEvent.LIST_ENTRY_CREATED: ListEntryCreatedBody,
|
|
278
|
+
WebhookEvent.FIELD_VALUE_UPDATED: FieldValueUpdatedBody,
|
|
279
|
+
WebhookEvent.PERSON_CREATED: WebhookPerson,
|
|
280
|
+
WebhookEvent.ORGANIZATION_CREATED: WebhookOrganization,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def dispatch_webhook(
|
|
286
|
+
envelope: WebhookEnvelope,
|
|
287
|
+
*,
|
|
288
|
+
registry: BodyRegistry = DEFAULT_BODY_REGISTRY,
|
|
289
|
+
) -> ParsedWebhook[BaseModel | dict[str, Any]]:
|
|
290
|
+
"""
|
|
291
|
+
Parse the envelope body using a registry and return a `ParsedWebhook`.
|
|
292
|
+
|
|
293
|
+
If the event type has no registered parser, the body remains a dict.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
parsed_body = registry.parse_body(envelope.type, envelope.body)
|
|
297
|
+
return ParsedWebhook(
|
|
298
|
+
type=envelope.type,
|
|
299
|
+
body=parsed_body,
|
|
300
|
+
sent_at=envelope.sent_at,
|
|
301
|
+
sent_at_epoch=envelope.sent_at_epoch,
|
|
302
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Affinity data models.
|
|
3
|
+
|
|
4
|
+
All Pydantic models are available from this module.
|
|
5
|
+
|
|
6
|
+
Tip:
|
|
7
|
+
ID types and enums live in `affinity.types`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# Core entities
|
|
13
|
+
from .entities import (
|
|
14
|
+
# List
|
|
15
|
+
AffinityList,
|
|
16
|
+
# Base
|
|
17
|
+
AffinityModel,
|
|
18
|
+
# Company
|
|
19
|
+
Company,
|
|
20
|
+
CompanyCreate,
|
|
21
|
+
CompanyUpdate,
|
|
22
|
+
DropdownOption,
|
|
23
|
+
FieldCreate,
|
|
24
|
+
# Field
|
|
25
|
+
FieldMetadata,
|
|
26
|
+
FieldValue,
|
|
27
|
+
FieldValueChange,
|
|
28
|
+
FieldValueCreate,
|
|
29
|
+
ListCreate,
|
|
30
|
+
# List Entry
|
|
31
|
+
ListEntry,
|
|
32
|
+
ListEntryCreate,
|
|
33
|
+
ListEntryWithEntity,
|
|
34
|
+
ListPermission,
|
|
35
|
+
ListSummary,
|
|
36
|
+
# Opportunity
|
|
37
|
+
Opportunity,
|
|
38
|
+
OpportunityCreate,
|
|
39
|
+
OpportunitySummary,
|
|
40
|
+
OpportunityUpdate,
|
|
41
|
+
# Person
|
|
42
|
+
Person,
|
|
43
|
+
PersonCreate,
|
|
44
|
+
PersonUpdate,
|
|
45
|
+
# Saved View
|
|
46
|
+
SavedView,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Pagination
|
|
50
|
+
from .pagination import (
|
|
51
|
+
AsyncPageIterator,
|
|
52
|
+
BatchOperationResponse,
|
|
53
|
+
BatchOperationResult,
|
|
54
|
+
PageIterator,
|
|
55
|
+
PaginatedResponse,
|
|
56
|
+
PaginationInfo,
|
|
57
|
+
PaginationProgress,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Rate limit snapshot (unified)
|
|
61
|
+
from .rate_limit_snapshot import RateLimitBucket, RateLimitSnapshot
|
|
62
|
+
|
|
63
|
+
# Secondary models
|
|
64
|
+
from .secondary import (
|
|
65
|
+
# File
|
|
66
|
+
EntityFile,
|
|
67
|
+
Grant,
|
|
68
|
+
# Interaction
|
|
69
|
+
Interaction,
|
|
70
|
+
InteractionCreate,
|
|
71
|
+
InteractionUpdate,
|
|
72
|
+
# Note
|
|
73
|
+
Note,
|
|
74
|
+
NoteCreate,
|
|
75
|
+
NoteUpdate,
|
|
76
|
+
RateLimitInfo,
|
|
77
|
+
RateLimits,
|
|
78
|
+
# Relationship
|
|
79
|
+
RelationshipStrength,
|
|
80
|
+
# Reminder
|
|
81
|
+
Reminder,
|
|
82
|
+
ReminderCreate,
|
|
83
|
+
ReminderUpdate,
|
|
84
|
+
Tenant,
|
|
85
|
+
WebhookCreate,
|
|
86
|
+
# Webhook
|
|
87
|
+
WebhookSubscription,
|
|
88
|
+
WebhookUpdate,
|
|
89
|
+
# Auth
|
|
90
|
+
WhoAmI,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
# Base
|
|
95
|
+
"AffinityModel",
|
|
96
|
+
# Person
|
|
97
|
+
"Person",
|
|
98
|
+
"PersonCreate",
|
|
99
|
+
"PersonUpdate",
|
|
100
|
+
# Company
|
|
101
|
+
"Company",
|
|
102
|
+
"CompanyCreate",
|
|
103
|
+
"CompanyUpdate",
|
|
104
|
+
# Opportunity
|
|
105
|
+
"Opportunity",
|
|
106
|
+
"OpportunityCreate",
|
|
107
|
+
"OpportunitySummary",
|
|
108
|
+
"OpportunityUpdate",
|
|
109
|
+
# List
|
|
110
|
+
"AffinityList",
|
|
111
|
+
"ListSummary",
|
|
112
|
+
"ListCreate",
|
|
113
|
+
"ListPermission",
|
|
114
|
+
# List Entry
|
|
115
|
+
"ListEntry",
|
|
116
|
+
"ListEntryCreate",
|
|
117
|
+
"ListEntryWithEntity",
|
|
118
|
+
# Field
|
|
119
|
+
"FieldMetadata",
|
|
120
|
+
"FieldCreate",
|
|
121
|
+
"FieldValue",
|
|
122
|
+
"FieldValueChange",
|
|
123
|
+
"FieldValueCreate",
|
|
124
|
+
"DropdownOption",
|
|
125
|
+
# Saved View
|
|
126
|
+
"SavedView",
|
|
127
|
+
# Note
|
|
128
|
+
"Note",
|
|
129
|
+
"NoteCreate",
|
|
130
|
+
"NoteUpdate",
|
|
131
|
+
# Reminder
|
|
132
|
+
"Reminder",
|
|
133
|
+
"ReminderCreate",
|
|
134
|
+
"ReminderUpdate",
|
|
135
|
+
# Webhook
|
|
136
|
+
"WebhookSubscription",
|
|
137
|
+
"WebhookCreate",
|
|
138
|
+
"WebhookUpdate",
|
|
139
|
+
# Interaction
|
|
140
|
+
"Interaction",
|
|
141
|
+
"InteractionCreate",
|
|
142
|
+
"InteractionUpdate",
|
|
143
|
+
# File
|
|
144
|
+
"EntityFile",
|
|
145
|
+
# Relationship
|
|
146
|
+
"RelationshipStrength",
|
|
147
|
+
# Auth
|
|
148
|
+
"WhoAmI",
|
|
149
|
+
"RateLimits",
|
|
150
|
+
"RateLimitInfo",
|
|
151
|
+
"RateLimitBucket",
|
|
152
|
+
"RateLimitSnapshot",
|
|
153
|
+
"Tenant",
|
|
154
|
+
"Grant",
|
|
155
|
+
# Pagination
|
|
156
|
+
"PaginationInfo",
|
|
157
|
+
"PaginationProgress",
|
|
158
|
+
"PaginatedResponse",
|
|
159
|
+
"PageIterator",
|
|
160
|
+
"AsyncPageIterator",
|
|
161
|
+
"BatchOperationResponse",
|
|
162
|
+
"BatchOperationResult",
|
|
163
|
+
]
|