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,165 @@
1
+ """
2
+ Internal request pipeline primitives.
3
+
4
+ The SDK models requests/responses independently of the underlying HTTP transport
5
+ so cross-cutting behavior can be implemented as middleware.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Generic, Literal, Protocol, TypeAlias, TypedDict, TypeVar, cast
13
+
14
+ Header: TypeAlias = tuple[str, str]
15
+
16
+ R = TypeVar("R", bound="SDKBaseResponse")
17
+ S = TypeVar("S")
18
+
19
+
20
+ class RequestContext(TypedDict, total=False):
21
+ cache_key: str
22
+ cache_ttl: float
23
+ # Stable per-operation correlation ID generated by RequestId middleware.
24
+ client_request_id: str
25
+ # Monotonic start time captured by HooksMiddleware (seconds).
26
+ started_at: float
27
+ # Total wall-clock budget for the whole operation (redirects + streaming iteration).
28
+ deadline_seconds: float
29
+ # Used by hooks middleware to allow inner middleware (redirect/stream wrappers) to emit events.
30
+ emit_event: Callable[[Any], Any]
31
+ # RequestInfo stored by HooksMiddleware for reuse by inner middleware.
32
+ hook_request_info: Any
33
+ external: bool
34
+ ever_external: bool
35
+ safe_follow: bool
36
+ streaming: bool
37
+ chunk_size: int
38
+ on_progress: Any
39
+ timeout: Any
40
+ timeout_seconds: float
41
+ tenant_hash: str
42
+
43
+
44
+ class ResponseContext(TypedDict, total=False):
45
+ cache_hit: bool
46
+ client_request_id: str
47
+ external: bool
48
+ ever_external: bool
49
+ http_version: str
50
+ request_id: str
51
+ elapsed_seconds: float
52
+ retry_count: int
53
+
54
+
55
+ class SDKBaseResponse(Protocol):
56
+ status_code: int
57
+ headers: list[Header]
58
+ context: ResponseContext
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class SDKRequest:
63
+ method: str
64
+ url: str
65
+ headers: list[Header] = field(default_factory=list)
66
+ params: Sequence[tuple[str, str]] | None = None
67
+ json: Any | None = None
68
+ files: Mapping[str, Any] | None = None
69
+ data: Mapping[str, Any] | None = None
70
+ api_version: Literal["v1", "v2"] = "v2"
71
+ write_intent: bool = False
72
+ context: RequestContext = field(default_factory=lambda: cast(RequestContext, {}))
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class SDKResponse:
77
+ status_code: int
78
+ headers: list[Header]
79
+ content: bytes
80
+ json: Any | None = None
81
+ context: ResponseContext = field(default_factory=lambda: cast(ResponseContext, {}))
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class SDKRawResponse:
86
+ status_code: int
87
+ headers: list[Header]
88
+ content: bytes
89
+ context: ResponseContext = field(default_factory=lambda: cast(ResponseContext, {}))
90
+
91
+
92
+ class SDKRawStream(Protocol):
93
+ def __enter__(self) -> SDKRawStream: ...
94
+
95
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None: ...
96
+
97
+ def iter_bytes(self, *, chunk_size: int) -> Iterator[bytes]: ...
98
+
99
+ def close(self) -> None: ...
100
+
101
+
102
+ class SDKAsyncRawStream(Protocol):
103
+ async def __aenter__(self) -> SDKAsyncRawStream: ...
104
+
105
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: ...
106
+
107
+ def aiter_bytes(self, *, chunk_size: int) -> AsyncIterator[bytes]: ...
108
+
109
+ async def aclose(self) -> None: ...
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class SDKRawStreamResponse(Generic[S]):
114
+ status_code: int
115
+ headers: list[Header]
116
+ stream: S
117
+ context: ResponseContext = field(default_factory=lambda: cast(ResponseContext, {}))
118
+
119
+
120
+ Pipeline: TypeAlias = Callable[[SDKRequest], R]
121
+ AsyncPipeline: TypeAlias = Callable[[SDKRequest], Awaitable[R]]
122
+
123
+
124
+ class Middleware(Protocol[R]):
125
+ def __call__(self, req: SDKRequest, next: Pipeline[R]) -> R: ...
126
+
127
+
128
+ class AsyncMiddleware(Protocol[R]):
129
+ async def __call__(self, req: SDKRequest, next: AsyncPipeline[R]) -> R: ...
130
+
131
+
132
+ def compose(middlewares: Sequence[Middleware[R]], terminal: Pipeline[R]) -> Pipeline[R]:
133
+ pipeline: Pipeline[R] = terminal
134
+ for middleware in reversed(middlewares):
135
+ next_pipeline = pipeline
136
+
137
+ def _wrapped(
138
+ req: SDKRequest,
139
+ *,
140
+ _mw: Middleware[R] = middleware,
141
+ _n: Pipeline[R] = next_pipeline,
142
+ ) -> R:
143
+ return _mw(req, _n)
144
+
145
+ pipeline = _wrapped
146
+ return pipeline
147
+
148
+
149
+ def compose_async(
150
+ middlewares: Sequence[AsyncMiddleware[R]], terminal: AsyncPipeline[R]
151
+ ) -> AsyncPipeline[R]:
152
+ pipeline: AsyncPipeline[R] = terminal
153
+ for middleware in reversed(middlewares):
154
+ next_pipeline = pipeline
155
+
156
+ async def _wrapped(
157
+ req: SDKRequest,
158
+ *,
159
+ _mw: AsyncMiddleware[R] = middleware,
160
+ _n: AsyncPipeline[R] = next_pipeline,
161
+ ) -> R:
162
+ return await _mw(req, _n)
163
+
164
+ pipeline = _wrapped
165
+ return pipeline
affinity/compare.py ADDED
@@ -0,0 +1,501 @@
1
+ """Shared comparison logic for filter matching.
2
+
3
+ Used by both:
4
+ - SDK filter (affinity/filters.py) for client-side filtering of API responses
5
+ - Query tool (affinity/cli/query/filters.py) for in-memory query filtering
6
+
7
+ This module is the single source of truth for comparison operations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ def normalize_value(value: Any) -> Any:
16
+ """Normalize a value for comparison.
17
+
18
+ Handles Affinity API response formats:
19
+ - Extracts "text" from dropdown dicts: {"text": "Active"} -> "Active"
20
+ - Extracts text values from multi-select arrays: [{"text": "A"}, {"text": "B"}] -> ["A", "B"]
21
+ - Passes through scalars unchanged
22
+
23
+ Args:
24
+ value: The value to normalize (from API response)
25
+
26
+ Returns:
27
+ Normalized value suitable for comparison
28
+ """
29
+ if value is None:
30
+ return None
31
+
32
+ # Handle dropdown dict: {"text": "Active", "id": 123} -> "Active"
33
+ if isinstance(value, dict) and "text" in value:
34
+ return value["text"]
35
+
36
+ # Handle multi-select array: [{"text": "A"}, {"text": "B"}] -> ["A", "B"]
37
+ if isinstance(value, list) and value and isinstance(value[0], dict) and "text" in value[0]:
38
+ return [item.get("text") for item in value if isinstance(item, dict)]
39
+
40
+ return value
41
+
42
+
43
+ def compare_values(
44
+ field_value: Any,
45
+ target: Any,
46
+ operator: str,
47
+ ) -> bool:
48
+ """Compare field value against target using the specified operator.
49
+
50
+ This is the core comparison function used by both SDK filter and Query tool.
51
+
52
+ Handles:
53
+ - Scalar comparisons (string, number)
54
+ - Array membership (multi-select dropdown fields)
55
+ - Set equality for array-to-array comparisons
56
+
57
+ Operators:
58
+ - eq: equality or array membership
59
+ - neq: not equal or not in array
60
+ - contains: substring match (case-insensitive)
61
+ - starts_with: prefix match (case-insensitive)
62
+ - ends_with: suffix match (case-insensitive)
63
+ - gt, gte, lt, lte: numeric/date comparisons
64
+ - in: value in list of allowed values
65
+ - between: value in range [low, high] (inclusive)
66
+ - has_any: array has any of target values (exact match)
67
+ - has_all: array has all of target values (exact match)
68
+ - contains_any: any element contains any substring (case-insensitive)
69
+ - contains_all: any element contains all substrings (case-insensitive)
70
+ - is_null: value is None or empty string
71
+ - is_not_null: value is not None and not empty string
72
+ - is_empty: value is empty (empty string or empty array)
73
+
74
+ Args:
75
+ field_value: The value from the entity/record being filtered
76
+ target: The target value to compare against
77
+ operator: The comparison operator name
78
+
79
+ Returns:
80
+ True if the comparison passes, False otherwise
81
+
82
+ Raises:
83
+ ValueError: If the operator is not recognized
84
+ """
85
+ # Dispatch to specific comparison function
86
+ if operator == "eq":
87
+ return _eq(field_value, target)
88
+ elif operator == "neq":
89
+ return _neq(field_value, target)
90
+ elif operator == "contains":
91
+ return _contains(field_value, target)
92
+ elif operator == "starts_with":
93
+ return _starts_with(field_value, target)
94
+ elif operator == "ends_with":
95
+ return _ends_with(field_value, target)
96
+ elif operator == "gt":
97
+ return _gt(field_value, target)
98
+ elif operator == "gte":
99
+ return _gte(field_value, target)
100
+ elif operator == "lt":
101
+ return _lt(field_value, target)
102
+ elif operator == "lte":
103
+ return _lte(field_value, target)
104
+ elif operator == "in":
105
+ return _in(field_value, target)
106
+ elif operator == "between":
107
+ return _between(field_value, target)
108
+ elif operator == "has_any":
109
+ return _has_any(field_value, target)
110
+ elif operator == "has_all":
111
+ return _has_all(field_value, target)
112
+ elif operator == "contains_any":
113
+ return _contains_any(field_value, target)
114
+ elif operator == "contains_all":
115
+ return _contains_all(field_value, target)
116
+ elif operator == "is_null":
117
+ return _is_null(field_value, target)
118
+ elif operator == "is_not_null":
119
+ return _is_not_null(field_value, target)
120
+ elif operator == "is_empty":
121
+ return _is_empty(field_value, target)
122
+ else:
123
+ raise ValueError(
124
+ f"Unknown comparison operator: '{operator}'. "
125
+ f"Valid operators: eq, neq, contains, starts_with, ends_with, "
126
+ f"gt, gte, lt, lte, in, between, has_any, has_all, "
127
+ f"contains_any, contains_all, is_null, is_not_null, is_empty"
128
+ )
129
+
130
+
131
+ # =============================================================================
132
+ # Comparison Functions
133
+ # =============================================================================
134
+
135
+
136
+ def _eq(a: Any, b: Any) -> bool:
137
+ """Equality operator with array membership support.
138
+
139
+ For scalar fields: standard equality (a == b), with string coercion fallback
140
+ For array fields (multi-select dropdowns):
141
+ - eq with scalar: checks if scalar is IN the array (membership)
142
+ - eq with array: checks set equality (order-insensitive, same elements)
143
+ """
144
+ if a is None:
145
+ return b is None
146
+
147
+ # If field value is a list, check if filter value is IN the list
148
+ if isinstance(a, list):
149
+ # If comparing list to list, check set equality (order-insensitive)
150
+ if isinstance(b, list):
151
+ try:
152
+ return set(a) == set(b)
153
+ except TypeError:
154
+ # Unhashable elements - fall back to sorted comparison
155
+ try:
156
+ return sorted(a) == sorted(b)
157
+ except TypeError:
158
+ return a == b # Last resort: order-sensitive equality
159
+ return b in a
160
+
161
+ # Try direct comparison first
162
+ if a == b:
163
+ return True
164
+
165
+ # Handle boolean coercion: filter string "true"/"false" should match Python bool
166
+ # Filter parser outputs lowercase "true"/"false", Python str(True) is "True"
167
+ if isinstance(a, bool) and isinstance(b, str):
168
+ return (a is True and b.lower() == "true") or (a is False and b.lower() == "false")
169
+ if isinstance(b, bool) and isinstance(a, str):
170
+ return (b is True and a.lower() == "true") or (b is False and a.lower() == "false")
171
+
172
+ # Fall back to string comparison for type mismatches (e.g., int 5 vs string "5")
173
+ # This is important for SDK filter where parsed values are always strings
174
+ return str(a) == str(b)
175
+
176
+
177
+ def _neq(a: Any, b: Any) -> bool:
178
+ """Not equal operator with array membership support."""
179
+ if a is None:
180
+ return b is not None
181
+
182
+ if isinstance(a, list):
183
+ if isinstance(b, list):
184
+ try:
185
+ return set(a) != set(b)
186
+ except TypeError:
187
+ try:
188
+ return sorted(a) != sorted(b)
189
+ except TypeError:
190
+ return a != b
191
+ return b not in a
192
+
193
+ # Use _eq for consistency (handles string coercion)
194
+ return not _eq(a, b)
195
+
196
+
197
+ def _contains(a: Any, b: Any) -> bool:
198
+ """Contains operator (case-insensitive substring match).
199
+
200
+ For scalar targets: substring match
201
+ For list targets (V2 API collection syntax): checks if field array contains ALL elements
202
+ - `tags =~ [A, B]` means "tags array contains both A and B" (has_all semantics)
203
+
204
+ For array fields with scalar target: checks if ANY element contains the substring.
205
+ """
206
+ if a is None or b is None:
207
+ return False
208
+
209
+ # V2 API collection syntax: =~ [a, b] means "contains all elements"
210
+ # This is the official Affinity V2 API behavior for collection contains
211
+ if isinstance(b, list):
212
+ if not isinstance(a, list):
213
+ return False
214
+ # Check if field array contains ALL elements from filter list
215
+ return all(elem in a for elem in b)
216
+
217
+ # Handle array fields - check if any element contains the substring
218
+ if isinstance(a, list):
219
+ b_lower = str(b).lower()
220
+ return any(b_lower in str(elem).lower() for elem in a)
221
+
222
+ return str(b).lower() in str(a).lower()
223
+
224
+
225
+ def _starts_with(a: Any, b: Any) -> bool:
226
+ """Starts with operator (case-insensitive prefix match).
227
+
228
+ For array fields: checks if ANY element starts with the prefix.
229
+ """
230
+ if a is None or b is None:
231
+ return False
232
+
233
+ # Handle array fields
234
+ if isinstance(a, list):
235
+ b_lower = str(b).lower()
236
+ return any(str(elem).lower().startswith(b_lower) for elem in a)
237
+
238
+ return str(a).lower().startswith(str(b).lower())
239
+
240
+
241
+ def _ends_with(a: Any, b: Any) -> bool:
242
+ """Ends with operator (case-insensitive suffix match).
243
+
244
+ For array fields: checks if ANY element ends with the suffix.
245
+ """
246
+ if a is None or b is None:
247
+ return False
248
+
249
+ # Handle array fields
250
+ if isinstance(a, list):
251
+ b_lower = str(b).lower()
252
+ return any(str(elem).lower().endswith(b_lower) for elem in a)
253
+
254
+ return str(a).lower().endswith(str(b).lower())
255
+
256
+
257
+ def _safe_compare(a: Any, b: Any, op: Any) -> bool:
258
+ """Safely compare values, handling None and type mismatches.
259
+
260
+ For numeric comparisons, attempts to coerce string values to numbers.
261
+ This is important for SDK filter where parsed values are always strings.
262
+ """
263
+ if a is None or b is None:
264
+ return False
265
+
266
+ # Try numeric coercion first (for SDK filter where parsed values are strings)
267
+ try:
268
+ # If both can be converted to numbers, compare as numbers
269
+ a_num = float(a) if isinstance(a, str) else a
270
+ b_num = float(b) if isinstance(b, str) else b
271
+ return bool(op(a_num, b_num))
272
+ except (TypeError, ValueError):
273
+ pass
274
+
275
+ try:
276
+ return bool(op(a, b))
277
+ except TypeError:
278
+ # Type mismatch - try string comparison
279
+ return bool(op(str(a), str(b)))
280
+
281
+
282
+ def _gt(a: Any, b: Any) -> bool:
283
+ """Greater than operator."""
284
+ return _safe_compare(a, b, lambda x, y: x > y)
285
+
286
+
287
+ def _gte(a: Any, b: Any) -> bool:
288
+ """Greater than or equal operator."""
289
+ return _safe_compare(a, b, lambda x, y: x >= y)
290
+
291
+
292
+ def _lt(a: Any, b: Any) -> bool:
293
+ """Less than operator."""
294
+ return _safe_compare(a, b, lambda x, y: x < y)
295
+
296
+
297
+ def _lte(a: Any, b: Any) -> bool:
298
+ """Less than or equal operator."""
299
+ return _safe_compare(a, b, lambda x, y: x <= y)
300
+
301
+
302
+ def _in(a: Any, b: Any) -> bool:
303
+ """In operator - checks if value(s) exist in filter list.
304
+
305
+ For scalar fields: checks if field value is in filter list
306
+ For array fields: checks if ANY element of field array is in filter list
307
+ """
308
+ if a is None:
309
+ return False
310
+ if not isinstance(b, list):
311
+ return False
312
+
313
+ # If a is a list, check if ANY element of a is in b
314
+ if isinstance(a, list):
315
+ return any(item in b for item in a)
316
+
317
+ return a in b
318
+
319
+
320
+ def _between(a: Any, b: Any) -> bool:
321
+ """Between operator (inclusive range).
322
+
323
+ Target must be a list of exactly 2 elements: [low, high].
324
+ Handles string-to-number coercion for parsed filter values.
325
+ """
326
+ if a is None or not isinstance(b, list) or len(b) != 2:
327
+ return False
328
+
329
+ low, high = b[0], b[1]
330
+
331
+ # Try numeric coercion first (filter values are often parsed as strings)
332
+ try:
333
+ a_num = float(a) if isinstance(a, str) else a
334
+ low_num = float(low) if isinstance(low, str) else low
335
+ high_num = float(high) if isinstance(high, str) else high
336
+ return bool(low_num <= a_num <= high_num)
337
+ except (TypeError, ValueError):
338
+ pass
339
+
340
+ # Fall back to direct comparison
341
+ try:
342
+ return bool(low <= a <= high)
343
+ except TypeError:
344
+ return False
345
+
346
+
347
+ def _has_any(a: Any, b: Any) -> bool:
348
+ """Check if array field contains ANY of the specified elements.
349
+
350
+ Unlike contains_any (which does case-insensitive substring matching),
351
+ this does exact array membership checking.
352
+ """
353
+ if not isinstance(a, list) or not isinstance(b, list):
354
+ return False
355
+ if not b: # Empty filter list = no match
356
+ return False
357
+ return any(elem in a for elem in b)
358
+
359
+
360
+ def _has_all(a: Any, b: Any) -> bool:
361
+ """Check if array field contains ALL of the specified elements.
362
+
363
+ Unlike contains_all (which does case-insensitive substring matching),
364
+ this does exact array membership checking.
365
+ """
366
+ if not isinstance(a, list) or not isinstance(b, list):
367
+ return False
368
+ if not b: # Empty filter list = no match (avoid vacuous truth)
369
+ return False
370
+ return all(elem in a for elem in b)
371
+
372
+
373
+ def _contains_any(a: Any, b: Any) -> bool:
374
+ """Contains any of the given terms (case-insensitive substring match).
375
+
376
+ For array fields: checks across all elements.
377
+ """
378
+ if a is None or not isinstance(b, list):
379
+ return False
380
+
381
+ # Handle array fields - check if any element contains any term
382
+ if isinstance(a, list):
383
+ for elem in a:
384
+ elem_lower = str(elem).lower()
385
+ if any(str(term).lower() in elem_lower for term in b):
386
+ return True
387
+ return False
388
+
389
+ a_lower = str(a).lower()
390
+ return any(str(term).lower() in a_lower for term in b)
391
+
392
+
393
+ def _contains_all(a: Any, b: Any) -> bool:
394
+ """Contains all of the given terms (case-insensitive substring match).
395
+
396
+ For scalar fields: all terms must be found in the single value.
397
+ For array fields: all terms must be found (can be across different elements).
398
+ """
399
+ if a is None or not isinstance(b, list):
400
+ return False
401
+
402
+ # Handle array fields - all terms must be found somewhere in the elements
403
+ if isinstance(a, list):
404
+ for term in b:
405
+ term_lower = str(term).lower()
406
+ found = any(term_lower in str(elem).lower() for elem in a)
407
+ if not found:
408
+ return False
409
+ return True
410
+
411
+ a_lower = str(a).lower()
412
+ return all(str(term).lower() in a_lower for term in b)
413
+
414
+
415
+ def _is_null(a: Any, _b: Any) -> bool:
416
+ """Is null operator.
417
+
418
+ Uses SDK filter semantics: empty string is treated as null-equivalent.
419
+ This is more useful for CRM data where empty strings often mean "not set".
420
+ """
421
+ return a is None or a == ""
422
+
423
+
424
+ def _is_not_null(a: Any, _b: Any) -> bool:
425
+ """Is not null operator.
426
+
427
+ Uses SDK filter semantics: empty string is treated as null-equivalent.
428
+ """
429
+ return a is not None and a != ""
430
+
431
+
432
+ def _is_empty(a: Any, _b: Any) -> bool:
433
+ """Is empty operator.
434
+
435
+ Checks if value is:
436
+ - None
437
+ - Empty string ""
438
+ - Empty array []
439
+ """
440
+ if a is None:
441
+ return True
442
+ if a == "":
443
+ return True
444
+ return isinstance(a, list) and len(a) == 0
445
+
446
+
447
+ # =============================================================================
448
+ # Operator Name Mapping
449
+ # =============================================================================
450
+
451
+ # Map from SDK filter symbols/aliases to canonical operator names
452
+ SDK_OPERATOR_MAP: dict[str, str] = {
453
+ # Official V2 API symbolic operators
454
+ "=": "eq",
455
+ "!=": "neq",
456
+ "=~": "contains",
457
+ "=^": "starts_with",
458
+ "=$": "ends_with",
459
+ ">": "gt",
460
+ ">=": "gte",
461
+ "<": "lt",
462
+ "<=": "lte",
463
+ # Word-based aliases (SDK extensions for LLM/human clarity)
464
+ "contains": "contains",
465
+ "starts_with": "starts_with",
466
+ "ends_with": "ends_with",
467
+ "gt": "gt",
468
+ "gte": "gte",
469
+ "lt": "lt",
470
+ "lte": "lte",
471
+ # Null/empty check aliases
472
+ "is null": "is_null",
473
+ "is not null": "is_not_null",
474
+ "is empty": "is_empty",
475
+ # Collection operators (SDK extensions)
476
+ "in": "in",
477
+ "between": "between",
478
+ "has_any": "has_any",
479
+ "has_all": "has_all",
480
+ "contains_any": "contains_any",
481
+ "contains_all": "contains_all",
482
+ }
483
+
484
+
485
+ def map_operator(sdk_operator: str) -> str:
486
+ """Map an SDK filter operator symbol to the canonical operator name.
487
+
488
+ Args:
489
+ sdk_operator: The operator as used in SDK filter strings (e.g., "=~", "contains")
490
+
491
+ Returns:
492
+ The canonical operator name (e.g., "contains")
493
+
494
+ Raises:
495
+ ValueError: If the operator is not recognized
496
+ """
497
+ canonical = SDK_OPERATOR_MAP.get(sdk_operator)
498
+ if canonical is None:
499
+ valid_ops = ", ".join(sorted(set(SDK_OPERATOR_MAP.keys())))
500
+ raise ValueError(f"Unknown operator: '{sdk_operator}'. Valid operators: {valid_ops}")
501
+ return canonical