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
|
@@ -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
|